From 12e820729d33aafa724bf8edf3cbd63a09eb0e4b Mon Sep 17 00:00:00 2001 From: oxsteins Date: Fri, 26 Jun 2026 15:17:41 +0530 Subject: [PATCH 01/50] feat(3f): multi-adapter bridge facilitator with signed-payload auth Rework the 3F Bridge Facilitator from a single-adapter, API-key model into a multi-adapter, signed-payload model: - Adapter-as-facilitator auth: drop the API key and offer-address. Create offers as signed payloads and list them with a per-adapter EIP-712 GetOffers Authorization header, authorized via the adapter's EIP-1271 isValidSignature. Adds GetOffersDigest (grunt-api domain, no verifyingContract, chainId=1) with golden + apitypes + live tests. - Config takes an adapters list. Each adapter's vault and collateral are resolved on-chain at startup in two batched Multicalls, and the solver verifies it is the adapter's EIP-1271 offerSigner, dropping any it isn't and shutting down if none match (no redeem-only mode). - Per-auction coverage: cover an auction's full requested amount in a single pass with one or more single-adapter offers (most-fundable first, each sized to the uncovered remainder), gated on live coverage so a fully-covered auction is never re-offered. The return floor (minRequestYieldBps) is enforced at selection. Offer dedup is keyed by (adapter, auction) and carries principal; redeem and reconcile run for every matched adapter, failing soft per adapter. --- config/3f.sepolia.example.yaml | 8 +- .../solvers/bridgefacilitator/apiclient.go | 274 ++---------- .../bridgefacilitator/apiclient_test.go | 49 +++ .../solvers/bridgefacilitator/auctionview.go | 11 +- .../solvers/bridgefacilitator/chainreader.go | 88 +++- .../bridgefacilitator/chainreader_test.go | 154 +++++++ internal/solvers/bridgefacilitator/config.go | 44 +- .../solvers/bridgefacilitator/config_test.go | 41 +- internal/solvers/bridgefacilitator/eip712.go | 27 +- .../solvers/bridgefacilitator/eip712_test.go | 115 +++++ .../bridgefacilitator/liveauth_test.go | 33 +- internal/solvers/bridgefacilitator/offer.go | 37 +- .../solvers/bridgefacilitator/offercache.go | 59 ++- .../bridgefacilitator/offercache_test.go | 43 +- .../solvers/bridgefacilitator/selection.go | 22 + .../bridgefacilitator/selection_test.go | 34 ++ internal/solvers/bridgefacilitator/solver.go | 411 +++++++++--------- 17 files changed, 886 insertions(+), 564 deletions(-) create mode 100644 internal/solvers/bridgefacilitator/apiclient_test.go create mode 100644 internal/solvers/bridgefacilitator/selection.go create mode 100644 internal/solvers/bridgefacilitator/selection_test.go diff --git a/config/3f.sepolia.example.yaml b/config/3f.sepolia.example.yaml index 4d889cd5..ccc42808 100644 --- a/config/3f.sepolia.example.yaml +++ b/config/3f.sepolia.example.yaml @@ -23,8 +23,12 @@ solvers: - name: 3f-bridge-facilitator config: apiBaseUrl: https://bf.dev.gcp.3f.xyz - adapter: "0x0000000000000000000000000000000000000000" # TODO: deployed BridgeFacilitatorAdapter (vault + collateral derived from it at startup) - # Exposure/return caps live on the adapter (setExposureLimits), read on-chain — not config. + # Adapters this solver maintains offers for. Each must be registered with 3F as a facilitator by + # its vault creator, with this solver's signer set as the adapter's EIP-1271 signer. Vault + + # collateral are resolved from each adapter at startup; exposure/return caps live on the adapter + # (read on-chain, not config). No API key — offers authenticate via the adapter's EIP-1271 check. + adapters: + - "0x0000000000000000000000000000000000000000" # TODO: a deployed BridgeFacilitatorAdapter intervals: discover: 1h redeemPoll: 5m diff --git a/internal/solvers/bridgefacilitator/apiclient.go b/internal/solvers/bridgefacilitator/apiclient.go index f471d2a9..c30e0e08 100644 --- a/internal/solvers/bridgefacilitator/apiclient.go +++ b/internal/solvers/bridgefacilitator/apiclient.go @@ -2,8 +2,6 @@ package bridgefacilitator import ( "context" - "crypto/sha256" - "encoding/hex" "math/big" "net/http" "strings" @@ -12,172 +10,39 @@ import ( "github.com/go-errors/errors" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/go-logr/logr" "github.com/symbioticfi/vault-solver/api/threef" "github.com/symbioticfi/vault-solver/internal/signer" ) -// keyRegenCooldown is the minimum spacing between generate-key calls. 3F rate-limits the endpoint -// ("API key was generated recently; try again later" → HTTP 429), so once we've just minted a key -// we must not immediately mint another. Crucially, a 401/403 right after issuing a key is an -// authorization problem with the facilitator/resource, NOT an expired key — regenerating would both -// trip the rate limit and revoke the working key, so within this window we surface the failure -// instead of regenerating. Legitimate mid-run expiry (hours later) is well outside the window. -const keyRegenCooldown = 2 * time.Minute +// getOffersDeadlineWindow is how far in the future the signed GetOffers deadline is set. +const getOffersDeadlineWindow = 5 * time.Minute -// apiClient wraps the generated 3F client. It injects the x-api-key header, lazily generates the -// key (EIP-712, signed by the facilitator), and reactively re-generates on a 401/403 — the 3F key -// has no documented TTL (a new generate-key revokes the prior key), so rather than assume a -// lifetime we refresh on demonstrated auth failure, rate-limited by keyRegenCooldown. +// apiClient wraps the generated 3F client. It signs per-adapter requests via EIP-712 and injects +// the resulting Authorization: Bearer header. // -// All methods are called from the single solver Run goroutine, so the cached key needs no lock. +// All methods are called from the single solver Run goroutine; no locking is required. type apiClient struct { - c *threef.APIClient - sgnr signer.Signer - facilitator common.Address - fallbackKey string // operator-provided key (apiKeyEnv); used if self-generation is unavailable - apiKey string - lastGenerate time.Time // when generate-key was last attempted, to honor 3F's rate limit - log logr.Logger + c *threef.APIClient + sgnr signer.Signer + log logr.Logger } -func newAPIClient( - baseURL string, timeout time.Duration, sgnr signer.Signer, facilitator common.Address, fallbackKey string, log logr.Logger, -) (*apiClient, error) { - if baseURL == "" { - return nil, errors.New("3f api: base URL is required") - } +func newAPIClient(baseURL string, sgnr signer.Signer, timeout time.Duration, log logr.Logger) *apiClient { cfg := threef.NewConfiguration() cfg.Servers = threef.ServerConfigurations{{URL: baseURL}} - // Bound every call: the generated client otherwise falls back to http.DefaultClient (no timeout), - // so a hung request would stall the single solver loop, redemption scans included. + // Bound every call; the generated client otherwise uses http.DefaultClient (no timeout) and a hung + // request would stall the single solver loop, redemption scans included. cfg.HTTPClient = &http.Client{Timeout: timeout} - ac := &apiClient{ - c: threef.NewAPIClient(cfg), - sgnr: sgnr, - facilitator: facilitator, - fallbackKey: fallbackKey, - log: log, - } - if fallbackKey != "" { - ac.setKey(fallbackKey, "env fallback") - } - return ac, nil -} - -// setKey records the active x-api-key and logs a non-reversible fingerprint (not the key) so an -// operator can tell which key is active without the secret ever landing in logs. -func (ac *apiClient) setKey(key, source string) { - ac.apiKey = key - ac.log.V(1).Info("3F API key set", "source", source, "fingerprint", keyFingerprint(key)) -} - -// keyFingerprint is a short, non-reversible identifier for a secret, for log correlation only. -func keyFingerprint(key string) string { - if key == "" { - return "(empty)" - } - sum := sha256.Sum256([]byte(key)) - return hex.EncodeToString(sum[:4]) -} - -// ensureKey makes sure a key is available, generating one if needed. -func (ac *apiClient) ensureKey(ctx context.Context) error { - if ac.apiKey != "" { - return nil - } - return ac.refreshKey(ctx) -} - -// refreshKey generates a fresh key (revoking any prior one). If generation is unavailable (e.g. the -// facilitator isn't onboarded yet) and an operator key was supplied, it falls back to that. -// -// Within keyRegenCooldown of the last generate-key attempt it refuses to regenerate and returns an -// error: the existing key is the freshest 3F will issue, so a preceding 401/403 reflects an -// authorization problem (not expiry) and regenerating would only 429 and revoke the working key. -func (ac *apiClient) refreshKey(ctx context.Context) error { - if !ac.lastGenerate.IsZero() && time.Since(ac.lastGenerate) < keyRegenCooldown { - if ac.apiKey != "" { - return errors.Errorf("3f api: key generated %s ago (within the %s regen cooldown); "+ - "auth failure is not key expiry — not regenerating", - time.Since(ac.lastGenerate).Round(time.Second), keyRegenCooldown) - } - // No usable key and still cooling down (e.g. a prior process generated recently). - if ac.fallbackKey != "" { - ac.setKey(ac.fallbackKey, "env fallback") - return nil - } - return errors.Errorf("3f api: generate-key on cooldown (last attempt %s ago) and no key available", - time.Since(ac.lastGenerate).Round(time.Second)) - } - key, err := ac.generate(ctx) - if err != nil { - if ac.fallbackKey != "" { - ac.setKey(ac.fallbackKey, "env fallback") - return nil - } - return err - } - ac.setKey(key, "generated") - return nil -} - -// generate signs the EIP-712 GenerateFacilitatorApiKey message and returns the issued key. It -// records the attempt time (arming keyRegenCooldown) even on failure, so a 429 can't be hammered. -func (ac *apiClient) generate(ctx context.Context) (string, error) { - ac.lastGenerate = time.Now() - deadline := big.NewInt(time.Now().Add(generateKeyDeadline).Unix()) - sig, err := ac.sgnr.SignHash(APIKeyDigest(ac.facilitator, deadline)) - if err != nil { - return "", errors.Errorf("3f api: sign generate-key: %w", err) - } - dto := *threef.NewGenerateFacilitatorApiKeyDto( - apiKeyDomainChainID, - lowerAddr(ac.facilitator), - deadline.String(), - hexutil.Encode(sig), - ) - resp, httpResp, err := ac.c.FacilitatorAPI.AdminControllerGenerateKeyV1(ctx). - GenerateFacilitatorApiKeyDto(dto).Execute() - closeResp(httpResp) - if err != nil { - return "", errors.Errorf("3f api: generate-key: %s: %w", statusOf(httpResp), err) + return &apiClient{ + c: threef.NewAPIClient(cfg), + sgnr: sgnr, + log: log, } - if resp == nil { - return "", errors.Errorf("3f api: generate-key: empty response (%s)", statusOf(httpResp)) - } - apiKey, ok := resp.GetApiKeyOk() - if !ok || apiKey == nil || *apiKey == "" { - return "", errors.Errorf("3f api: generate-key: response missing apiKey (%s)", statusOf(httpResp)) - } - return *apiKey, nil -} - -// withAuth runs an authed call, ensuring a key first and regenerating + retrying once on 401/403. -// `do` performs one attempt and returns the HTTP status of that attempt (so the auth-failure retry -// can trigger) plus any transport/decoding error. -func (ac *apiClient) withAuth(ctx context.Context, do func() (int, error)) error { - if err := ac.ensureKey(ctx); err != nil { - return err - } - status, err := do() - if status == http.StatusUnauthorized || status == http.StatusForbidden { - if rErr := ac.refreshKey(ctx); rErr != nil { - return errors.Errorf("3f api: re-auth after %d: %w", status, rErr) - } - return wrapAttempt(do()) - } - return err } -// wrapAttempt collapses a (status, err) attempt into a single error (status is irrelevant once the -// retry has run — the error, if any, is what the caller cares about). -func wrapAttempt(_ int, err error) error { return err } - -// listAuctions returns the current auctions, each carrying its EIP-712 domain (needed for signing). -// No auth needed here. +// listAuctions returns the current auctions, each carrying its EIP-712 domain (needed for signing); no auth required. func (ac *apiClient) listAuctions(ctx context.Context) ([]threef.AuctionDto, error) { auctions, httpResp, err := ac.c.AuctionAPI.AuctionControllerListV1(ctx).Domain(true).Execute() closeResp(httpResp) @@ -189,107 +54,42 @@ func (ac *apiClient) listAuctions(ctx context.Context) ([]threef.AuctionDto, err // createOffer submits a signed offer. func (ac *apiClient) createOffer(ctx context.Context, dto threef.CreateOfferDto) error { - err := ac.withAuth(ctx, func() (int, error) { - _, httpResp, e := ac.c.OfferAPI.OfferControllerCreateV1(ctx). - XApiKey(ac.apiKey).CreateOfferDto(dto).Execute() - closeResp(httpResp) - if e != nil { - return statusCode(httpResp), errors.Errorf("3f api: create offer: %s: %w", statusOf(httpResp), e) - } - return statusCode(httpResp), nil - }) - if err != nil { - return errors.Errorf("3f api: create offer: %w", err) + _, httpResp, e := ac.c.OfferAPI.OfferControllerCreateV1(ctx).CreateOfferDto(dto).Execute() + closeResp(httpResp) + if e != nil { + return errors.Errorf("3f api: create offer: %s: %w", statusOf(httpResp), e) } return nil } -// listOffers returns the facilitator's offers. Used at startup to rebuild the offer-dedup cache so a -// restart doesn't re-offer on auctions we already have live offers for. -// -// On the x-api-key path the API requires `maker` to be the facilitator's own broker address (not the -// adapter); it then returns offers under that address AND under the facilitator's configured -// offer-address — which is our adapter (see ensureOfferAddress). So querying by the facilitator -// surfaces our adapter's offers. (Querying maker=adapter here returns 403: that scope needs an -// EIP-712 GetOffers signature instead of the api key.) -func (ac *apiClient) listOffers(ctx context.Context) ([]threef.OfferDto, error) { - makerLower := lowerAddr(ac.facilitator) - var offers []threef.OfferDto - err := ac.withAuth(ctx, func() (int, error) { - o, httpResp, e := ac.c.OfferAPI.OfferControllerGetV1(ctx). - Maker(makerLower).XApiKey(ac.apiKey).Execute() - closeResp(httpResp) - if e != nil { - return statusCode(httpResp), errors.Errorf("3f api: list offers: %s: %w", statusOf(httpResp), e) - } - offers = o - return statusCode(httpResp), nil - }) - if err != nil { - return nil, errors.Errorf("3f api: list offers: %w", err) - } - return offers, nil -} - -// offerAddress returns the facilitator's currently-registered offer (maker) address, or the zero -// address if none is set. -func (ac *apiClient) offerAddress(ctx context.Context) (common.Address, error) { - var addr common.Address - err := ac.withAuth(ctx, func() (int, error) { - resp, httpResp, e := ac.c.FacilitatorAPI.AdminControllerGetFacilitatorOfferAddressV1(ctx). - XApiKey(ac.apiKey).Execute() - closeResp(httpResp) - if e != nil { - return statusCode(httpResp), errors.Errorf("3f api: get offer-address: %s: %w", statusOf(httpResp), e) - } - if s, ok := resp.GetOfferAddressOk(); ok && s != nil && common.IsHexAddress(*s) { - addr = common.HexToAddress(*s) - } - return statusCode(httpResp), nil - }) +// listOffers returns the adapter's outstanding offers. Authenticated via a per-adapter EIP-712 +// GetOffers signature in the Authorization: Bearer header — no API key required. +func (ac *apiClient) listOffers(ctx context.Context, adapter common.Address) ([]threef.OfferDto, error) { + deadline := big.NewInt(time.Now().Add(getOffersDeadlineWindow).Unix()) + sig, err := ac.sgnr.SignHash(GetOffersDigest(adapter, deadline)) if err != nil { - return common.Address{}, errors.Errorf("3f api: get offer-address: %w", err) + return nil, errors.Errorf("3f api: sign GetOffers: %w", err) } - return addr, nil -} - -// setOfferAddress registers `addr` as the facilitator's offer (maker) address. -func (ac *apiClient) setOfferAddress(ctx context.Context, addr common.Address) error { - dto := *threef.NewSetFacilitatorOfferAddressDto(lowerAddr(addr)) - err := ac.withAuth(ctx, func() (int, error) { - _, httpResp, e := ac.c.FacilitatorAPI.AdminControllerSetFacilitatorOfferAddressV1(ctx). - XApiKey(ac.apiKey).SetFacilitatorOfferAddressDto(dto).Execute() - closeResp(httpResp) - if e != nil { - return statusCode(httpResp), errors.Errorf("3f api: set offer-address: %s: %w", statusOf(httpResp), e) - } - return statusCode(httpResp), nil - }) - if err != nil { - return errors.Errorf("3f api: set offer-address: %w", err) + o, httpResp, e := ac.c.OfferAPI.OfferControllerGetV1(ctx). + Maker(lowerAddr(adapter)). + Deadline(deadline.String()). + Authorization("Bearer 0x" + common.Bytes2Hex(sig)). + Execute() + closeResp(httpResp) + if e != nil { + return nil, errors.Errorf("3f api: list offers: %s: %w", statusOf(httpResp), e) } - return nil + return o, nil } -// closeResp closes the HTTP response body. The generated client already reads the body fully and -// closes it inside Execute, so this is a harmless no-op that satisfies the "body must be closed" -// contract without a lint suppression (bodyclose can't see across the Execute call boundary). +// 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) { if resp != nil && resp.Body != nil { _ = resp.Body.Close() } } -// statusCode returns the HTTP status code of resp, or 0 if resp is nil (e.g. a transport error -// before any response). The auth-retry logic keys off this, so a nil response must not look like -// a 401/403. -func statusCode(resp *http.Response) int { - if resp == nil { - return 0 - } - return resp.StatusCode -} - // statusOf renders an HTTP response's status for error context ("no response" if there was none). func statusOf(resp *http.Response) string { if resp == nil { diff --git a/internal/solvers/bridgefacilitator/apiclient_test.go b/internal/solvers/bridgefacilitator/apiclient_test.go new file mode 100644 index 00000000..c96f807f --- /dev/null +++ b/internal/solvers/bridgefacilitator/apiclient_test.go @@ -0,0 +1,49 @@ +package bridgefacilitator + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/go-logr/logr" +) + +// fakeSigner is a minimal signer.Signer test double that signs nothing meaningful (65 zero bytes). +type fakeSigner struct{} + +func (fakeSigner) Address() common.Address { return common.Address{} } +func (fakeSigner) SignHash(_ common.Hash) ([]byte, error) { + return make([]byte, 65), nil +} +func (fakeSigner) SignTx(tx *types.Transaction, _ *big.Int) (*types.Transaction, error) { + return tx, nil +} + +func TestAPIClient_ListOffers_SignedPerAdapter(t *testing.T) { + var gotMaker, gotAuth, gotKey, gotDeadline string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMaker = r.URL.Query().Get("maker") + gotDeadline = r.URL.Query().Get("deadline") + gotAuth = r.Header.Get("Authorization") + gotKey = r.Header.Get("x-api-key") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + adapter := common.HexToAddress("0x0000000000000000000000000000000000000042") + ac := newAPIClient(srv.URL, fakeSigner{}, 5*time.Second, logr.Discard()) + if _, err := ac.listOffers(context.Background(), adapter); err != nil { + t.Fatalf("listOffers: %v", err) + } + if gotMaker != lowerAddr(adapter) || gotDeadline == "" || + !strings.HasPrefix(gotAuth, "Bearer 0x") || gotKey != "" { + t.Fatalf("maker=%q deadline=%q auth=%q key=%q", gotMaker, gotDeadline, gotAuth, gotKey) + } +} diff --git a/internal/solvers/bridgefacilitator/auctionview.go b/internal/solvers/bridgefacilitator/auctionview.go index 2f4e2524..22f39b6a 100644 --- a/internal/solvers/bridgefacilitator/auctionview.go +++ b/internal/solvers/bridgefacilitator/auctionview.go @@ -52,14 +52,15 @@ func (a auctionView) requestAddr() common.Address { return common.HexToAddress(a.dto.RequestId) } -// maxRate returns the auction's current max rate (basis points) as a float64, or 0 if the API -// didn't resolve it (for logging only). -func (a auctionView) maxRate() float64 { +// maxRateBps returns the auction's current max rate (basis points) and whether the API resolved it. +// It prices every offer and gates the per-adapter return floor, so an unresolved rate means we can't +// bid on the auction at all. +func (a auctionView) maxRateBps() (float64, bool) { r, ok := a.dto.GetMaxRateOk() if !ok || r == nil { - return 0 + return 0, false } - return float64(*r) + return float64(*r), true } // amountRequested returns the requested principal, or nil if the API didn't resolve it. diff --git a/internal/solvers/bridgefacilitator/chainreader.go b/internal/solvers/bridgefacilitator/chainreader.go index dc92e2fa..fcbe63c0 100644 --- a/internal/solvers/bridgefacilitator/chainreader.go +++ b/internal/solvers/bridgefacilitator/chainreader.go @@ -49,31 +49,83 @@ func newReader(c *chain.Client) *reader { return &reader{chain: c, delegatorCache: make(map[common.Address]common.Address)} } -// adapterVault returns the vault the adapter funds (bound once at adapter initialize), so config -// carries only the adapter address and the bot derives the vault at startup. -func (r *reader) adapterVault(ctx context.Context, adapterAddr common.Address) (common.Address, error) { - res, err := r.chain.Multicall(ctx, []chain.Call{{Target: adapterAddr, Data: bfAdapter.PackVault()}}) - if err != nil { - return common.Address{}, err +// resolvedAdapter is one adapter's startup resolution: its vault, that vault's collateral (the +// ERC-4626 asset, used to match auctions), and its EIP-1271 offer-signer. err is set (other fields +// zero) if any read reverted, so the caller can drop just that adapter. +type resolvedAdapter struct { + vault common.Address + collateral common.Address + signer common.Address + err error +} + +// decodeAddr returns the address a Multicall sub-call returned, or an error tagged with `what` if it +// reverted or failed to decode. +func decodeAddr(res chain.CallResult, unpack func([]byte) (common.Address, error), what string) (common.Address, error) { + if !res.Success { + return common.Address{}, errors.Errorf("%s reverted", what) } - if len(res) != 1 || !res[0].Success { - return common.Address{}, errors.New("adapter.vault() reverted") + addr, err := unpack(res.ReturnData) + if err != nil { + return common.Address{}, errors.Errorf("decode %s: %w", what, err) } - return bfAdapter.UnpackVault(res[0].ReturnData) + return addr, nil } -// vaultAsset returns the vault's collateral token, used to match auctions (by deposit asset) to this -// funding vault. In the core-mirror VaultV2 the deposit/collateral token is the ERC-4626 asset, so -// this reads IERC4626(vault).asset() (the old vault.collateral() no longer exists). -func (r *reader) vaultAsset(ctx context.Context, vault common.Address) (common.Address, error) { - res, err := r.chain.Multicall(ctx, []chain.Call{{Target: vault, Data: erc4626b.PackAsset()}}) +// resolveAdapters resolves every adapter's vault, collateral, and offer-signer in two Multicalls +// regardless of adapter count: round 1 batches each adapter's vault()+offerSigner(); round 2 batches +// asset() on the vaults round 1 returned. Per-call AllowFailure isolates a bad adapter to its own err; +// a returned error is a whole-batch RPC failure. +func (r *reader) resolveAdapters(ctx context.Context, adapters []common.Address) ([]resolvedAdapter, error) { + out := make([]resolvedAdapter, len(adapters)) + + calls := make([]chain.Call, 0, 2*len(adapters)) + for _, a := range adapters { + calls = append(calls, + chain.Call{Target: a, Data: bfAdapter.PackVault(), AllowFailure: true}, + chain.Call{Target: a, Data: bfAdapter.PackOfferSigner(), AllowFailure: true}, + ) + } + res, err := r.chain.Multicall(ctx, calls) if err != nil { - return common.Address{}, err + return nil, err } - if len(res) != 1 || !res[0].Success { - return common.Address{}, errors.New("vault.asset() reverted") + + // Decode round 1; queue an asset() call for each adapter whose vault and signer both resolved. + assetCalls := make([]chain.Call, 0, len(adapters)) + assetIdx := make([]int, 0, len(adapters)) // assetIdx[k] = out index of assetCalls[k] + for i := range adapters { + vault, derr := decodeAddr(res[2*i], bfAdapter.UnpackVault, "adapter.vault()") + if derr != nil { + out[i].err = derr + continue + } + signer, derr := decodeAddr(res[2*i+1], bfAdapter.UnpackOfferSigner, "adapter.offerSigner()") + if derr != nil { + out[i].err = derr + continue + } + out[i].vault, out[i].signer = vault, signer + assetCalls = append(assetCalls, chain.Call{Target: vault, Data: erc4626b.PackAsset(), AllowFailure: true}) + assetIdx = append(assetIdx, i) + } + if len(assetCalls) == 0 { + return out, nil + } + + ares, err := r.chain.Multicall(ctx, assetCalls) + if err != nil { + return nil, err + } + for k, idx := range assetIdx { + collateral, derr := decodeAddr(ares[k], erc4626b.UnpackAsset, "vault.asset()") + if derr != nil { + out[idx].err = derr + continue + } + out[idx].collateral = collateral } - return erc4626b.UnpackAsset(res[0].ReturnData) + return out, nil } // vaultDelegator resolves the vault's delegator address (the contract that holds the per-adapter diff --git a/internal/solvers/bridgefacilitator/chainreader_test.go b/internal/solvers/bridgefacilitator/chainreader_test.go index 3edc6ced..2042b008 100644 --- a/internal/solvers/bridgefacilitator/chainreader_test.go +++ b/internal/solvers/bridgefacilitator/chainreader_test.go @@ -1,8 +1,20 @@ package bridgefacilitator import ( + "context" + "encoding/json" + "fmt" "math/big" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/chain" ) // TestDeriveLiquidity covers the pure reduction of the core-mirror on-chain reads @@ -101,6 +113,148 @@ func TestDeriveLiquidity(t *testing.T) { } } +// newMulticallFakeClient returns a chain.Client backed by a minimal JSON-RPC httptest server. +// The server responds to eth_chainId and eth_call; ethCallReplies are the hex-encoded bytes returned +// by successive eth_call requests (i.e. each ABI-encoded Multicall3.aggregate3 Result[] array). With +// one reply it serves that every call; with several it serves them in order (e.g. round 1 then round +// 2 of resolveAdapters), sticking on the last once exhausted. +func newMulticallFakeClient(t *testing.T, ethCallReplies ...[]byte) (*chain.Client, func()) { + t.Helper() + multicallAddr := common.HexToAddress("0x0000000000000000000000000000000000000001") + var ethCallN atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID any `json:"id"` + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_chainId": + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%v,"result":"0x1"}`, marshalID(req.ID)) + case "eth_call": + i := int(ethCallN.Add(1)) - 1 + if i >= len(ethCallReplies) { + i = len(ethCallReplies) - 1 + } + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%v,"result":"0x%x"}`, marshalID(req.ID), ethCallReplies[i]) + default: + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%v,"error":{"code":-32601,"message":"method not found"}}`, marshalID(req.ID)) + } + })) + + c, err := chain.Dial(t.Context(), []string{srv.URL}, multicallAddr.Hex(), logr.Discard()) + if err != nil { + srv.Close() + t.Fatalf("chain.Dial: %v", err) + } + return c, srv.Close +} + +// marshalID renders a JSON-RPC request id (number or string) back to JSON so we can embed it in the +// response without re-encoding quotes. json.Marshal on an any holding a json.Number or string is +// always safe; if it somehow fails we fall back to a literal null which keeps the server response +// well-formed for the client. +func marshalID(id any) string { + b, err := json.Marshal(id) + if err != nil { + return "null" + } + return string(b) +} + +// abiEncodeAggregate3Results ABI-encodes a Multicall3.aggregate3 return value: one Result per inner +// payload, each Success=true with ReturnData=inner. This is the hex payload eth_call returns for a +// successful aggregate3 with len(inners) sub-call results. +func abiEncodeAggregate3Results(t *testing.T, inners ...[]byte) []byte { + t.Helper() + // aggregate3 returns (Result[] returnData) where Result = (bool success, bytes returnData). + resultTuple, err := abi.NewType("tuple[]", "", []abi.ArgumentMarshaling{ + {Name: "success", Type: "bool"}, + {Name: "returnData", Type: "bytes"}, + }) + if err != nil { + t.Fatalf("abi.NewType tuple[]: %v", err) + } + type result struct { + Success bool + ReturnData []byte + } + results := make([]result, len(inners)) + for i, inner := range inners { + results[i] = result{Success: true, ReturnData: inner} + } + encoded, err := abi.Arguments{{Type: resultTuple}}.Pack(results) + if err != nil { + t.Fatalf("abi args.Pack: %v", err) + } + return encoded +} + +// abiEncodeAddress ABI-encodes a single address as a 32-byte left-padded word (the raw returnData +// for a Solidity function returning address). +func abiEncodeAddress(t *testing.T, addr common.Address) []byte { + t.Helper() + addrType, err := abi.NewType("address", "", nil) + if err != nil { + t.Fatalf("abi.NewType address: %v", err) + } + enc, err := abi.Arguments{{Type: addrType}}.Pack(addr) + if err != nil { + t.Fatalf("abi address Pack: %v", err) + } + return enc +} + +// TestResolveAdapters verifies the two-Multicall batch resolves each adapter's vault, signer, and +// collateral and maps them back by index: round 1 returns [vault0, signer0, vault1, signer1] and +// round 2 returns [asset0, asset1], so a layout off-by-one would cross adapters' fields. +func TestResolveAdapters(t *testing.T) { + t.Parallel() + + adapters := []common.Address{ + common.HexToAddress("0x00000000000000000000000000000000000000A0"), + common.HexToAddress("0x00000000000000000000000000000000000000A1"), + } + vault0 := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer0 := common.HexToAddress("0x00000000000000000000000000000000000000C0") + asset0 := common.HexToAddress("0x00000000000000000000000000000000000000D0") + vault1 := common.HexToAddress("0x00000000000000000000000000000000000000B1") + signer1 := common.HexToAddress("0x00000000000000000000000000000000000000C1") + asset1 := common.HexToAddress("0x00000000000000000000000000000000000000D1") + + round1 := abiEncodeAggregate3Results(t, + abiEncodeAddress(t, vault0), abiEncodeAddress(t, signer0), + abiEncodeAddress(t, vault1), abiEncodeAddress(t, signer1), + ) + round2 := abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset0), abiEncodeAddress(t, asset1)) + + c, stop := newMulticallFakeClient(t, round1, round2) + defer stop() + + got, err := newReader(c).resolveAdapters(context.Background(), adapters) + if err != nil { + t.Fatalf("resolveAdapters: %v", err) + } + want := []resolvedAdapter{ + {vault: vault0, signer: signer0, collateral: asset0}, + {vault: vault1, signer: signer1, collateral: asset1}, + } + for i, w := range want { + if got[i].err != nil { + t.Fatalf("adapter %d: unexpected err %v", i, got[i].err) + } + if got[i].vault != w.vault || got[i].signer != w.signer || got[i].collateral != w.collateral { + t.Errorf("adapter %d = {vault:%s signer:%s collateral:%s}, want {vault:%s signer:%s collateral:%s}", + i, got[i].vault.Hex(), got[i].signer.Hex(), got[i].collateral.Hex(), + w.vault.Hex(), w.signer.Hex(), w.collateral.Hex()) + } + } +} + // TestDeriveLiquidityDoesNotMutateInputs guards against the clamp accidentally aliasing/mutating the // caller's *big.Int values (deriveLiquidity must allocate its own results). func TestDeriveLiquidityDoesNotMutateInputs(t *testing.T) { diff --git a/internal/solvers/bridgefacilitator/config.go b/internal/solvers/bridgefacilitator/config.go index c8825354..3469c557 100644 --- a/internal/solvers/bridgefacilitator/config.go +++ b/internal/solvers/bridgefacilitator/config.go @@ -1,6 +1,7 @@ package bridgefacilitator import ( + "strconv" "time" "github.com/go-errors/errors" @@ -11,13 +12,11 @@ import ( "github.com/symbioticfi/vault-solver/internal/solver" ) -// rawConfig mirrors the YAML shape; strings are parsed into typed values in parse(). 3F registers -// exactly one offer-address per facilitator, so the bot serves a single vault+adapter pair. +// rawConfig mirrors the YAML shape; strings are parsed into typed values in parse(). type rawConfig struct { APIBaseURL string `yaml:"apiBaseUrl"` - APIKeyEnv string `yaml:"apiKeyEnv"` RedeemBatchSize int `yaml:"redeemBatchSize"` - Adapter string `yaml:"adapter"` + Adapters []string `yaml:"adapters"` HTTPTimeout string `yaml:"httpTimeout"` Intervals rawIntervals `yaml:"intervals"` } @@ -31,22 +30,19 @@ type rawIntervals struct { // Config is the validated, typed solver configuration. type Config struct { APIBaseURL string - // APIKeyEnv is the env var holding a pre-generated 3F API key (sent as the x-api-key header). - APIKeyEnv string // RedeemBatchSize caps how many Requests are redeemed in a single redeem() call (gas bound). RedeemBatchSize int // HTTPTimeout bounds every 3F API call so a hung request can't stall the single solver loop // (including redemption scans). Applied as the 3F http.Client timeout. HTTPTimeout time.Duration - // Target is the single vault+adapter pair this facilitator serves. 3F allows exactly one - // offer-address per facilitator, so this solver is single-pair by construction. - Target Target + // Targets is the list of vault+adapter pairs this facilitator serves. + Targets []Target Intervals Intervals } -// Target is the adapter the bot facilitates. Only the adapter is config: Vault (adapter.vault()) and -// Collateral (vault.asset()) are resolved on-chain at startup (see Solver.resolveTarget) and fixed for -// the adapter's lifetime. Exposure/return caps also live on-chain (setExposureLimits), read each poll. +// Target is one adapter the bot facilitates. Only the adapter is config: Vault (adapter.vault()) and +// Collateral (vault.asset()) are resolved on-chain at startup (resolveTargets); exposure/return caps +// also live on-chain (setExposureLimits), read each poll. type Target struct { Adapter common.Address // Auctions are matched to this target by their deposit asset equalling Collateral. @@ -89,7 +85,7 @@ func parseConfig(node yaml.Node) (*Config, error) { redeemBatch = defaultRedeemBatchSize } - target, err := parseTarget(raw) + targets, err := parseTargets(raw) if err != nil { return nil, err } @@ -114,22 +110,26 @@ func parseConfig(node yaml.Node) (*Config, error) { return &Config{ APIBaseURL: raw.APIBaseURL, - APIKeyEnv: raw.APIKeyEnv, RedeemBatchSize: redeemBatch, HTTPTimeout: httpTimeout, - Target: target, + Targets: targets, Intervals: Intervals{Discover: discover, RedeemPoll: redeemPoll, Reconcile: reconcile}, }, nil } -func parseTarget(raw rawConfig) (Target, error) { - // The zero address is rejected so an unreplaced placeholder fails at startup rather than being - // registered as the 3F offer-address. - adapter, err := parseNonZeroAddress(raw.Adapter, "adapter") - if err != nil { - return Target{}, err +func parseTargets(raw rawConfig) ([]Target, error) { + if len(raw.Adapters) == 0 { + return nil, errors.New("at least one adapters entry is required") + } + targets := make([]Target, 0, len(raw.Adapters)) + for i, a := range raw.Adapters { + adapter, err := parseNonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") + if err != nil { + return nil, err + } + targets = append(targets, Target{Adapter: adapter}) } - return Target{Adapter: adapter}, nil + return targets, nil } func parseAddress(s, field string) (common.Address, error) { diff --git a/internal/solvers/bridgefacilitator/config_test.go b/internal/solvers/bridgefacilitator/config_test.go index 2e88fd9b..035b3a09 100644 --- a/internal/solvers/bridgefacilitator/config_test.go +++ b/internal/solvers/bridgefacilitator/config_test.go @@ -3,25 +3,36 @@ package bridgefacilitator import ( "testing" + "github.com/ethereum/go-ethereum/common" "gopkg.in/yaml.v3" ) -func mustParse(t *testing.T, body string) *Config { +func parse(t *testing.T, body string) (*Config, error) { t.Helper() var doc yaml.Node if err := yaml.Unmarshal([]byte(body), &doc); err != nil { t.Fatalf("unmarshal: %v", err) } - cfg, err := parseConfig(*doc.Content[0]) // Content[0] is the mapping node (as the two-stage decode yields) + return parseConfig(*doc.Content[0]) // Content[0] is the mapping node (as the two-stage decode yields) +} + +func mustParse(t *testing.T, body string) *Config { + t.Helper() + cfg, err := parse(t, body) if err != nil { t.Fatalf("parseConfig: %v", err) } return cfg } +const minimalConfig = ` +apiBaseUrl: https://bf.example +` + const oneTarget = ` apiBaseUrl: https://bf.example -adapter: "0x0000000000000000000000000000000000000002" +adapters: + - "0x0000000000000000000000000000000000000002" ` func TestParseConfig_RedeemBatchSizeDefaults(t *testing.T) { @@ -61,7 +72,8 @@ func TestParseConfig_InvalidDurationRejected(t *testing.T) { func TestParseConfig_ZeroAdapterRejected(t *testing.T) { body := ` apiBaseUrl: https://bf.example -adapter: "0x0000000000000000000000000000000000000000" +adapters: + - "0x0000000000000000000000000000000000000000" ` var doc yaml.Node if err := yaml.Unmarshal([]byte(body), &doc); err != nil { @@ -71,3 +83,24 @@ adapter: "0x0000000000000000000000000000000000000000" t.Fatal("expected zero adapter address to be rejected") } } + +func TestParseConfig_AdaptersList(t *testing.T) { + cfg, err := parse(t, minimalConfig+"adapters:\n - \"0x0000000000000000000000000000000000000042\"\n - \"0x0000000000000000000000000000000000000043\"\n") + if err != nil { + t.Fatalf("parseConfig: %v", err) + } + if len(cfg.Targets) != 2 || + cfg.Targets[0].Adapter != common.HexToAddress("0x0000000000000000000000000000000000000042") || + cfg.Targets[1].Adapter != common.HexToAddress("0x0000000000000000000000000000000000000043") { + t.Fatalf("targets = %+v", cfg.Targets) + } +} + +func TestParseConfig_RejectsEmptyAndZeroAdapters(t *testing.T) { + if _, err := parse(t, minimalConfig); err == nil { + t.Fatal("expected an error when no adapters are configured") + } + if _, err := parse(t, minimalConfig+"adapters:\n - \"0x0000000000000000000000000000000000000000\"\n"); err == nil { + t.Fatal("expected an error for a zero adapter address") + } +} diff --git a/internal/solvers/bridgefacilitator/eip712.go b/internal/solvers/bridgefacilitator/eip712.go index c5cbcc8b..c11ed543 100644 --- a/internal/solvers/bridgefacilitator/eip712.go +++ b/internal/solvers/bridgefacilitator/eip712.go @@ -121,14 +121,27 @@ var ( []byte("EIP712Domain(string name,string version,uint256 chainId)")) ) +// gruntAPIDomainSeparator is the EIP-712 domain separator shared by every grunt-api request +// (name/version/chainId=1, no verifyingContract). Computed once. +var gruntAPIDomainSeparator = crypto.Keccak256Hash( + apiKeyDomainTypeHash.Bytes(), + crypto.Keccak256([]byte(apiKeyDomainName)), + crypto.Keccak256([]byte(apiKeyDomainVersion)), + word(big.NewInt(apiKeyDomainChainID).Bytes()), +) + +// getOffersTypeHash is the EIP-712 type the maker signs to list its offers via the Authorization +// header; the field set is checked against the live 3F API in the GetOffers golden test. +var getOffersTypeHash = crypto.Keccak256Hash([]byte("GetOffers(address maker,uint256 deadline)")) + +// GetOffersDigest computes the EIP-712 digest signed for an authenticated GET /v1/offer (maker=adapter). +func GetOffersDigest(maker common.Address, deadline *big.Int) common.Hash { + sh := crypto.Keccak256Hash(getOffersTypeHash.Bytes(), word(maker.Bytes()), word(deadline.Bytes())) + return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator.Bytes(), sh.Bytes()) +} + // APIKeyDigest computes the EIP-712 digest a facilitator signs to generate a 3F API key. func APIKeyDigest(facilitator common.Address, deadline *big.Int) common.Hash { - ds := crypto.Keccak256Hash( - apiKeyDomainTypeHash.Bytes(), - crypto.Keccak256([]byte(apiKeyDomainName)), - crypto.Keccak256([]byte(apiKeyDomainVersion)), - word(big.NewInt(apiKeyDomainChainID).Bytes()), - ) sh := crypto.Keccak256Hash(apiKeyTypeHash.Bytes(), word(facilitator.Bytes()), word(deadline.Bytes())) - return crypto.Keccak256Hash([]byte{0x19, 0x01}, ds.Bytes(), sh.Bytes()) + return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator.Bytes(), sh.Bytes()) } diff --git a/internal/solvers/bridgefacilitator/eip712_test.go b/internal/solvers/bridgefacilitator/eip712_test.go index bfd67e04..20f44ba7 100644 --- a/internal/solvers/bridgefacilitator/eip712_test.go +++ b/internal/solvers/bridgefacilitator/eip712_test.go @@ -1,8 +1,14 @@ package bridgefacilitator import ( + "context" + "fmt" "math/big" + "net/http" + "os" + "strings" "testing" + "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -111,6 +117,115 @@ func TestAPIKeyDigest_MatchesLiveAcceptedSignature(t *testing.T) { } } +func TestGetOffersDigest_Golden(t *testing.T) { + maker := common.HexToAddress("0x0000000000000000000000000000000000000042") + got := GetOffersDigest(maker, big.NewInt(4102444800)).Hex() + // GOLDEN: pinned from TestGetOffersDigest_MatchesApitypes cross-check. + want := "0x9d4c2e5ccaaeb6884d2d2fd8e306e57cf781ef424db9e8801c703eac794fa6a5" + if got != want { + t.Fatalf("digest = %s, want %s", got, want) + } +} + +// TestGetOffersDigest_MatchesApitypes cross-checks our hand-rolled GetOffers digest against +// go-ethereum's independent EIP-712 implementation. The grunt-api domain has no verifyingContract +// (name/version/chainId=1 only), matching the same domain as APIKeyDigest. +func TestGetOffersDigest_MatchesApitypes(t *testing.T) { + maker := common.HexToAddress("0x0000000000000000000000000000000000000042") + deadline := big.NewInt(4102444800) + + got := GetOffersDigest(maker, deadline) + + typed := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + }, + "GetOffers": { + {Name: "maker", Type: "address"}, + {Name: "deadline", Type: "uint256"}, + }, + }, + PrimaryType: "GetOffers", + Domain: apitypes.TypedDataDomain{ + Name: apiKeyDomainName, + Version: apiKeyDomainVersion, + ChainId: math.NewHexOrDecimal256(apiKeyDomainChainID), + }, + Message: apitypes.TypedDataMessage{ + "maker": maker.Hex(), + "deadline": deadline.String(), + }, + } + domainSep, err := typed.HashStruct("EIP712Domain", typed.Domain.Map()) + if err != nil { + t.Fatalf("hash domain: %v", err) + } + msgHash, err := typed.HashStruct("GetOffers", 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. +// If the type string is wrong the API returns a 401/signature-error, which fails the test. +func TestGetOffersDigest_MatchesLiveAcceptedSignature(t *testing.T) { + if os.Getenv("SOLVER_LIVE_AUTH") != "1" { + t.Skip("set SOLVER_LIVE_AUTH=1 and SOLVER_PRIVATE_KEY to run the live 3F GetOffers auth check") + } + pk := os.Getenv("SOLVER_PRIVATE_KEY") + if pk == "" { + t.Fatal("SOLVER_PRIVATE_KEY not set") + } + key, err := crypto.HexToECDSA(strings.TrimPrefix(pk, "0x")) + if err != nil { + t.Fatalf("key: %v", err) + } + maker := crypto.PubkeyToAddress(key.PublicKey) + deadline := big.NewInt(4_102_444_800) + + sig, err := crypto.Sign(GetOffersDigest(maker, deadline).Bytes(), key) + if err != nil { + t.Fatalf("sign: %v", err) + } + sig[64] += 27 // normalize V to {27,28} + + baseURL := os.Getenv("SOLVER_3F_BASE_URL") + if baseURL == "" { + baseURL = "https://bf.dev.gcp.3f.xyz" + } + + url := fmt.Sprintf("%s/v1/offer?maker=%s&deadline=%s", baseURL, strings.ToLower(maker.Hex()), deadline.String()) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) //nolint:gosec // G704: URL is operator-supplied via SOLVER_3F_BASE_URL in this live integration test + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+hexutil.Encode(sig)) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) //nolint:gosec // G704: intentional operator-controlled target in live integration test + if err != nil { + t.Fatalf("GET /v1/offer: %v", err) + } + defer resp.Body.Close() + + // 200 (authorized) or 403 (maker not registered) both mean signature verification passed. + // Anything in the 4xx range that is specifically a signature error means the type string is wrong. + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden { + t.Fatalf("unexpected status %d — expected 200 or 403 (sig accepted); a 401 means the type string may be wrong", resp.StatusCode) + } + t.Logf("GET /v1/offer status %d (maker=%s) — signature accepted by 3F API", resp.StatusCode, maker.Hex()) +} + func TestOfferExpectedReturn(t *testing.T) { // 100,000 USDC (6 dp) at 200 bps (2%) => 2,000 USDC. principal := new(big.Int).SetUint64(100_000_000_000) diff --git a/internal/solvers/bridgefacilitator/liveauth_test.go b/internal/solvers/bridgefacilitator/liveauth_test.go index 04690cb2..a0ffb060 100644 --- a/internal/solvers/bridgefacilitator/liveauth_test.go +++ b/internal/solvers/bridgefacilitator/liveauth_test.go @@ -3,6 +3,7 @@ package bridgefacilitator import ( "context" "os" + "strings" "testing" "time" @@ -11,19 +12,18 @@ import ( "github.com/symbioticfi/vault-solver/internal/signer" ) -// TestLiveGenerateKey exercises the real 3F generate-key flow against the live API. It is skipped -// unless SOLVER_LIVE_AUTH=1 (so it never runs in CI), and needs SOLVER_PRIVATE_KEY in the env. A -// pass means the facilitator (the signer EOA) is onboarded and a key was issued; a 403 means the -// signature is accepted but the address isn't registered with 3F yet. -func TestLiveGenerateKey(t *testing.T) { +// TestLiveListOffers exercises the signed per-adapter GET /v1/offer flow against the live API. It is +// skipped unless SOLVER_LIVE_AUTH=1 (so it never runs in CI), and needs SOLVER_PRIVATE_KEY in the +// env. A pass (200 or 403) means the EIP-712 signature was accepted by the 3F API. +func TestLiveListOffers(t *testing.T) { if os.Getenv("SOLVER_LIVE_AUTH") != "1" { - t.Skip("set SOLVER_LIVE_AUTH=1 and SOLVER_PRIVATE_KEY to run the live 3F auth check") + t.Skip("set SOLVER_LIVE_AUTH=1 and SOLVER_PRIVATE_KEY to run the live 3F listOffers auth check") } pk := os.Getenv("SOLVER_PRIVATE_KEY") if pk == "" { t.Fatal("SOLVER_PRIVATE_KEY not set") } - sgnr, err := signer.NewFromHexKey(pk) + sgnr, err := signer.NewFromHexKey(strings.TrimPrefix(pk, "0x")) if err != nil { t.Fatalf("signer: %v", err) } @@ -33,24 +33,15 @@ func TestLiveGenerateKey(t *testing.T) { baseURL = "https://bf.dev.gcp.3f.xyz" } - ac, err := newAPIClient(baseURL, 30*time.Second, sgnr, sgnr.Address(), "", logr.Discard()) - if err != nil { - t.Fatalf("client: %v", err) - } + ac := newAPIClient(baseURL, sgnr, 30*time.Second, logr.Discard()) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - key, err := ac.generate(ctx) + // Use the signer's own address as the adapter for the live auth check. + offers, err := ac.listOffers(ctx, sgnr.Address()) if err != nil { - t.Fatalf("generate-key failed for facilitator %s:\n %v", sgnr.Address().Hex(), err) - } - t.Logf("AUTH OK — facilitator %s issued key %s", sgnr.Address().Hex(), maskKey(key)) -} - -func maskKey(s string) string { - if len(s) <= 10 { - return "***" + t.Fatalf("listOffers failed for adapter %s:\n %v", sgnr.Address().Hex(), err) } - return s[:10] + "…(redacted)" + t.Logf("AUTH OK — adapter %s returned %d offers", sgnr.Address().Hex(), len(offers)) } diff --git a/internal/solvers/bridgefacilitator/offer.go b/internal/solvers/bridgefacilitator/offer.go index e06d59bd..81abb078 100644 --- a/internal/solvers/bridgefacilitator/offer.go +++ b/internal/solvers/bridgefacilitator/offer.go @@ -15,43 +15,26 @@ import ( // offerTTL is how long a signed offer stays valid. const offerTTL = 30 * time.Minute -// generateKeyDeadline is the EIP-712 `deadline` for the generate-key request. The 3F spec labels -// it the "Signature deadline" (how long the signed request is valid, for replay protection) and -// its example uses a year-2100 value — it is NOT documented as the API key's TTL. We set it far -// out so it's safe under both readings: a non-expiring signature window, or (if 3F ties key life -// to it) a long-lived key. Either way, reactive regeneration on a 401/403 covers revoke/expire. -const generateKeyDeadline = 100 * 365 * 24 * time.Hour - -// buildSignedOffer prices and signs an offer for `request` at `principal`, with `maker` (the adapter) -// as the on-chain maker. `minYieldBps` is the adapter's on-chain return floor (0 = none); it returns -// ok=false (no error) when the auction's rate is below it, so the bot doesn't bid (the contract -// enforces the same floor at consume time). +// buildSignedOffer prices and signs an offer for `request` at `principal` and `rateBps`, with `maker` +// (the adapter) as the on-chain maker. The caller has already confirmed the rate clears the adapter's +// return floor (see offerAuction); the contract enforces it again at consume time. func (s *Solver) buildSignedOffer( - av auctionView, request, maker common.Address, principal, minYieldBps *big.Int, -) (threef.CreateOfferDto, bool, error) { + av auctionView, request, maker common.Address, principal *big.Int, rateBps float64, +) (threef.CreateOfferDto, error) { auction := av.dto - maxRate, ok := auction.GetMaxRateOk() - if !ok || maxRate == nil { - return threef.CreateOfferDto{}, false, nil - } - rateBps := float64(*maxRate) - if minYieldBps != nil && minYieldBps.Sign() > 0 && rateBps < bpsToFloat(minYieldBps) { - return threef.CreateOfferDto{}, false, nil // below the adapter's on-chain return floor - } - expectedReturn := offerExpectedReturn(principal, rateBps) domain, ok := auction.GetEip712DomainOk() if !ok || domain == nil { - return threef.CreateOfferDto{}, false, errors.Errorf("auction %v: missing EIP-712 domain", auction.Id) + return threef.CreateOfferDto{}, errors.Errorf("auction %v: missing EIP-712 domain", auction.Id) } domainName, ok := domain.GetNameOk() if !ok || domainName == nil { - return threef.CreateOfferDto{}, false, errors.Errorf("auction %v: missing EIP-712 domain name", auction.Id) + return threef.CreateOfferDto{}, errors.Errorf("auction %v: missing EIP-712 domain name", auction.Id) } domainChainID, ok := domain.GetChainIdOk() if !ok || domainChainID == nil { - return threef.CreateOfferDto{}, false, errors.Errorf("auction %v: missing EIP-712 domain chainId", auction.Id) + return threef.CreateOfferDto{}, errors.Errorf("auction %v: missing EIP-712 domain chainId", auction.Id) } chainID := big.NewInt(int64(*domainChainID)) // The EIP-712 domain version comes from the auction; fall back to grunt's known default only when @@ -75,7 +58,7 @@ func (s *Solver) buildSignedOffer( digest := OfferDigest(offer, *domainName, domainVersion, chainID, request) sig, err := s.deps.Signer.SignHash(digest) if err != nil { - return threef.CreateOfferDto{}, false, errors.Errorf("sign offer: %w", err) + return threef.CreateOfferDto{}, errors.Errorf("sign offer: %w", err) } dto := threef.NewCreateOfferDto( @@ -89,5 +72,5 @@ func (s *Solver) buildSignedOffer( ) dto.SetChainId(float32(chainID.Int64())) dto.SetSignature(hexutil.Encode(sig)) - return *dto, true, nil + return *dto, nil } diff --git a/internal/solvers/bridgefacilitator/offercache.go b/internal/solvers/bridgefacilitator/offercache.go index 6589cb17..6f6d1f5c 100644 --- a/internal/solvers/bridgefacilitator/offercache.go +++ b/internal/solvers/bridgefacilitator/offercache.go @@ -1,37 +1,66 @@ package bridgefacilitator import ( + "math/big" "strconv" "time" + + "github.com/ethereum/go-ethereum/common" ) -// offerTracker remembers, per auction, when our currently-outstanding offer expires, so we don't -// re-offer while a live offer exists. It is rebuilt from the 3F API at startup (restart-safe) and -// updated in memory as offers are submitted. Accessed only from the Run goroutine; no locking. +// offerKey identifies our offer on a given auction made on behalf of a given adapter (the maker). +// Dedup is per-adapter: two adapters may each hold a live offer on the same auction. +type offerKey struct { + adapter common.Address + auction int64 +} + +// offerState is one outstanding offer: when it expires and the principal it covers. +type offerState struct { + expiry time.Time + principal *big.Int +} + +// offerTracker remembers our outstanding offers per (adapter, auction) so we don't re-offer through +// the same adapter while one is live, and so we can tell when an auction is fully covered. Rebuilt +// from the 3F API at startup (restart-safe), updated in memory as offers are submitted; Run goroutine +// only, no locking. type offerTracker struct { - expiry map[int64]time.Time // auctionID -> our offer's expiration + offers map[offerKey]offerState } func newOfferTracker() *offerTracker { - return &offerTracker{expiry: make(map[int64]time.Time)} + return &offerTracker{offers: make(map[offerKey]offerState)} } -// hasLive reports whether we hold an unexpired offer for auctionID as of now. -func (t *offerTracker) hasLive(auctionID int64, now time.Time) bool { - exp, ok := t.expiry[auctionID] - return ok && exp.After(now) +// hasLive reports whether we hold an unexpired offer through adapter for auctionID as of now. +func (t *offerTracker) hasLive(adapter common.Address, auctionID int64, now time.Time) bool { + st, ok := t.offers[offerKey{adapter, auctionID}] + return ok && st.expiry.After(now) } -// record stores the expiration of an offer we hold for auctionID. -func (t *offerTracker) record(auctionID int64, expiration time.Time) { - t.expiry[auctionID] = expiration +// 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)} +} + +// liveCoverage sums the principal of our unexpired offers on auctionID across every adapter — how much +// of the auction's requested amount we already cover. +func (t *offerTracker) liveCoverage(auctionID int64, now time.Time) *big.Int { + total := new(big.Int) + for k, st := range t.offers { + if k.auction == auctionID && st.expiry.After(now) { + total.Add(total, st.principal) + } + } + return total } // pruneExpired drops entries whose offer has already expired, keeping the map bounded over a long run. func (t *offerTracker) pruneExpired(now time.Time) { - for id, exp := range t.expiry { - if !exp.After(now) { - delete(t.expiry, id) + for k, st := range t.offers { + if !st.expiry.After(now) { + delete(t.offers, k) } } } diff --git a/internal/solvers/bridgefacilitator/offercache_test.go b/internal/solvers/bridgefacilitator/offercache_test.go index 08279a30..aa8c1d29 100644 --- a/internal/solvers/bridgefacilitator/offercache_test.go +++ b/internal/solvers/bridgefacilitator/offercache_test.go @@ -1,30 +1,63 @@ package bridgefacilitator import ( + "math/big" "testing" "time" + + "github.com/ethereum/go-ethereum/common" ) func TestOfferTracker(t *testing.T) { tr := newOfferTracker() now := time.Unix(1_000_000, 0) + adapterA := common.Address{0xAA} + adapterB := common.Address{0xBB} - if tr.hasLive(42, now) { + if tr.hasLive(adapterA, 42, now) { t.Fatal("empty tracker should report no live offer") } - tr.record(42, now.Add(30*time.Minute)) - if !tr.hasLive(42, now) { + tr.record(adapterA, 42, now.Add(30*time.Minute), big.NewInt(100)) + if !tr.hasLive(adapterA, 42, now) { t.Fatal("offer should be live before expiry") } - if tr.hasLive(42, now.Add(31*time.Minute)) { + // Dedup is per-adapter: A's offer on auction 42 must not suppress B's offer on the same auction. + if tr.hasLive(adapterB, 42, now) { + t.Fatal("an offer through adapter A must not mark adapter B's offer on the same auction as live") + } + if tr.hasLive(adapterA, 42, now.Add(31*time.Minute)) { t.Fatal("offer should be expired after its TTL") } - if tr.hasLive(7, now) { + if tr.hasLive(adapterA, 7, now) { t.Fatal("unknown auction should not be live") } } +func TestOfferTrackerLiveCoverage(t *testing.T) { + tr := newOfferTracker() + now := time.Unix(1_000_000, 0) + adapterA := common.Address{0xAA} + adapterB := common.Address{0xBB} + + if got := tr.liveCoverage(42, now); got.Sign() != 0 { + t.Fatalf("empty tracker coverage = %s, want 0", got) + } + + // 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 + if got := tr.liveCoverage(42, now); got.Cmp(big.NewInt(160)) != 0 { + t.Fatalf("coverage = %s, want 160", got) + } + + // Expired offers don't count toward coverage. + if got := tr.liveCoverage(42, now.Add(31*time.Minute)); got.Sign() != 0 { + t.Fatalf("coverage after expiry = %s, want 0", got) + } +} + func TestParseUnixTime(t *testing.T) { got, err := parseUnixTime("4102444800") if err != nil { diff --git a/internal/solvers/bridgefacilitator/selection.go b/internal/solvers/bridgefacilitator/selection.go new file mode 100644 index 00000000..f14b9bf7 --- /dev/null +++ b/internal/solvers/bridgefacilitator/selection.go @@ -0,0 +1,22 @@ +package bridgefacilitator + +import "math/big" + +// adapterSizing pairs an adapter candidate with the principal it can fund for one auction. +type adapterSizing struct { + target Target + principal *big.Int +} + +// selectBestAdapter returns the candidate that can fund the largest principal (which, at the auction's +// fixed rate, maximizes expected return). Ties keep the earlier candidate; ok is false when empty. +func selectBestAdapter(candidates []adapterSizing) (adapterSizing, bool) { + var best adapterSizing + found := false + for _, c := range candidates { + if !found || c.principal.Cmp(best.principal) > 0 { + best, found = c, true + } + } + return best, found +} diff --git a/internal/solvers/bridgefacilitator/selection_test.go b/internal/solvers/bridgefacilitator/selection_test.go new file mode 100644 index 00000000..c731db0c --- /dev/null +++ b/internal/solvers/bridgefacilitator/selection_test.go @@ -0,0 +1,34 @@ +package bridgefacilitator + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestSelectBestAdapter(t *testing.T) { + cand := func(n byte, p int64) adapterSizing { + return adapterSizing{target: Target{Adapter: common.Address{n}}, principal: big.NewInt(p)} + } + + t.Run("picks the largest principal", func(t *testing.T) { + best, ok := selectBestAdapter([]adapterSizing{cand(1, 100), cand(2, 300), cand(3, 200)}) + if !ok || best.target.Adapter != (common.Address{2}) || best.principal.Int64() != 300 { + t.Fatalf("best = %+v ok=%v, want adapter 0x02 / 300", best, ok) + } + }) + + t.Run("no candidates", func(t *testing.T) { + if _, ok := selectBestAdapter(nil); ok { + t.Fatal("expected ok=false for no candidates") + } + }) + + t.Run("ties keep config order", func(t *testing.T) { + best, ok := selectBestAdapter([]adapterSizing{cand(1, 200), cand(2, 200)}) + if !ok || best.target.Adapter != (common.Address{1}) { + t.Fatalf("tie should keep the first (0x01), got %v", best.target.Adapter) + } + }) +} diff --git a/internal/solvers/bridgefacilitator/solver.go b/internal/solvers/bridgefacilitator/solver.go index 0779871f..717facea 100644 --- a/internal/solvers/bridgefacilitator/solver.go +++ b/internal/solvers/bridgefacilitator/solver.go @@ -7,17 +7,14 @@ package bridgefacilitator import ( "context" "math/big" - "os" "sync/atomic" "time" - "github.com/go-errors/errors" - "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" "github.com/go-logr/logr" "gopkg.in/yaml.v3" - "github.com/symbioticfi/vault-solver/api/threef" "github.com/symbioticfi/vault-solver/internal/solver" ) @@ -31,14 +28,14 @@ func init() { // Solver is the 3F Bridge Facilitator strategy. type Solver struct { - cfg *Config - deps solver.Deps - api *apiClient - reader *reader - log logr.Logger - nonceSeq atomic.Uint64 - onboarded bool // set once the 3F API key + offer-address are in place (Run goroutine only) - offers *offerTracker // dedup: auctions we hold a live offer for (Run goroutine only) + cfg *Config + deps solver.Deps + api *apiClient + reader *reader + log logr.Logger + signerAddr common.Address // the solver's own EIP-1271 signer address, set in factory + nonceSeq atomic.Uint64 + offers *offerTracker // dedup: (adapter, auction) pairs we hold a live offer for (Run goroutine only) } func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { @@ -47,27 +44,16 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { return nil, err } - var fallbackKey string - if cfg.APIKeyEnv != "" { - fallbackKey = os.Getenv(cfg.APIKeyEnv) - if fallbackKey == "" { - return nil, errors.Errorf("%s: api key env %q is empty", Name, cfg.APIKeyEnv) - } - } - // The facilitator (API-key owner) is the signing EOA; offers carry the per-target adapter as - // maker, registered as the facilitator offer-address at startup. - api, err := newAPIClient(cfg.APIBaseURL, cfg.HTTPTimeout, deps.Signer, deps.Signer.Address(), fallbackKey, deps.Log.WithName(Name)) - if err != nil { - return nil, err - } + api := newAPIClient(cfg.APIBaseURL, deps.Signer, cfg.HTTPTimeout, deps.Log.WithName(Name)) s := &Solver{ - cfg: cfg, - deps: deps, - api: api, - reader: newReader(deps.Chain), - log: deps.Log.WithName(Name), - offers: newOfferTracker(), + cfg: cfg, + deps: deps, + api: api, + reader: newReader(deps.Chain), + log: deps.Log.WithName(Name), + signerAddr: deps.Signer.Address(), + offers: newOfferTracker(), } // Seed the offer nonce sequence from the wall clock so it stays monotonic across restarts. s.nonceSeq.Store(uint64(time.Now().UnixNano())) @@ -80,21 +66,23 @@ func (s *Solver) Name() string { return Name } // Run drives discovery/offer, redemption, and reconciliation on their configured cadences until // ctx is cancelled. func (s *Solver) Run(ctx context.Context) error { - // Resolve the target's vault and collateral from the adapter once at startup (see resolveTarget). - s.resolveTarget(ctx) + // Resolve every adapter's vault/collateral and drop any for which this solver is not the + // authorised EIP-1271 signer (see resolveTargets). + if err := s.resolveTargets(ctx); err != nil { + return err + } s.log.Info("starting", - "adapter", s.cfg.Target.Adapter.Hex(), - "vault", s.cfg.Target.Vault.Hex(), + "adapters", len(s.cfg.Targets), "apiBaseUrl", s.cfg.APIBaseURL, "discover", s.cfg.Intervals.Discover.String(), ) - // Onboard once at startup. On failure the bot runs redeem-only (offers disabled) until restart; - // redemption and reconciliation are on-chain and need no API auth. - if err := s.onboard(ctx); err != nil { - s.log.Error(err, "3F onboarding failed; running redeem-only (offers disabled until restart)") - } + // Best-effort at startup: load existing offers so a restart doesn't re-offer where we already hold + // a live offer. Per-adapter failures are logged and skipped; a missing entry costs at most one + // redundant, bounded-safe offer. There is no redeem-only mode — startup either kept ≥1 matching + // adapter (above) and runs offers + redeems, or resolveTargets already shut the solver down. + s.rebuildOfferCache(ctx) discoverT := time.NewTicker(s.cfg.Intervals.Discover) redeemT := time.NewTicker(s.cfg.Intervals.RedeemPoll) @@ -121,193 +109,194 @@ func (s *Solver) Run(ctx context.Context) error { } } -// onboard ensures the 3F API key and offer-address registration are in place. It runs once at -// startup and sets s.onboarded, which gates discovery/offers. Mid-run key expiry is handled -// separately by apiClient.withAuth (regenerate + retry on 401/403), so onboard never needs to rerun. -func (s *Solver) onboard(ctx context.Context) error { - if err := s.api.ensureKey(ctx); err != nil { - return errors.Errorf("api key: %w", err) - } - if err := s.ensureOfferAddress(ctx); err != nil { - return err - } - // Rebuild the offer-dedup cache from the API so a restart doesn't re-offer on auctions we - // already hold live offers for. Non-fatal: an empty cache just risks one redundant (bounded-safe) - // offer per auction. - if err := s.rebuildOfferCache(ctx); err != nil { - s.log.Error(err, "could not load existing offers; starting with an empty offer cache") - } - s.onboarded = true - return nil -} - -// rebuildOfferCache loads the facilitator's outstanding offers (a single API call covering its -// broker address and its configured offer-address, i.e. our adapter) and records the expiration of -// each still-unexpired one, so discovery skips auctions we already cover. -func (s *Solver) rebuildOfferCache(ctx context.Context) error { +// rebuildOfferCache records each adapter's still-unexpired offers so discovery skips auctions we +// already cover. Best-effort: a per-adapter list failure is logged and skipped so one bad adapter +// can't blank the others' caches. +func (s *Solver) rebuildOfferCache(ctx context.Context) { now := time.Now() - offers, err := s.api.listOffers(ctx) - if err != nil { - return err - } live := 0 - for _, o := range offers { - exp, perr := parseUnixTime(o.Expiration) - if perr != nil || !exp.After(now) { - continue // unparseable or already expired — we may freely re-offer + for _, t := range s.cfg.Targets { + offers, err := s.api.listOffers(ctx, t.Adapter) + if err != nil { + s.log.Error(err, "rebuild offer cache: list offers", "adapter", t.Adapter.Hex()) + continue + } + for _, o := range offers { + exp, perr := parseUnixTime(o.Expiration) + 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) + 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) + live++ } - s.offers.record(int64(o.AuctionId), exp) - live++ } s.log.Info("loaded existing offers into dedup cache", "live", live) - return nil } -// discoverAndOffer lists auctions and offers per target. It runs only when onboarding succeeded; in -// redeem-only mode (onboarding failed at startup) it is a no-op — redemption and reconciliation run -// independently, since they're on-chain only. +// adapterOffering tracks one adapter's liquidity/exposure across an offer pass; committed and opened +// accumulate as bids land so later auctions see the reduced capacity (no over-commit, no re-read). +type adapterOffering struct { + target Target + st exposureState + committed *big.Int + opened int +} + +// discoverAndOffer lists open auctions and, for each, offers on behalf of the single best-fit adapter +// (the one that can fund the most). It reads every active adapter's liquidity/exposure once per pass. func (s *Solver) discoverAndOffer(ctx context.Context) { - if !s.onboarded { - s.log.V(1).Info("not onboarded; skipping discovery/offers (redeem-only mode)") - return - } auctions, err := s.api.listAuctions(ctx) if err != nil { s.log.Error(err, "discover: list auctions") return } + s.log.V(1).Info("discovered auctions", "count", len(auctions)) - s.log.V(1).Info("discovered auctions", "count", len(auctions), "auctions", auctions) - s.offerForTarget(ctx, s.cfg.Target, auctions) -} - -// offerForTarget reads the target's vault/adapter liquidity and exposure once, then bids on each -// matching auction. `committed`/`opened` accumulate this pass's offers so successive bids see the -// reduced capacity — preserving the no-over-commit guarantee without re-reading per auction. -func (s *Solver) offerForTarget(ctx context.Context, target Target, auctions []threef.AuctionDto) { - // One multicall fetches liquidity, live exposure, the open-loan count, and the adapter's caps. - st, err := s.reader.liquidityAndExposure(ctx, target.Vault, target.Adapter) - if err != nil { - s.log.Error(err, "offer: liquidity/exposure", "adapter", target.Adapter.Hex()) - return + offerings := make([]*adapterOffering, 0, len(s.cfg.Targets)) + for _, t := range s.cfg.Targets { + st, lerr := s.reader.liquidityAndExposure(ctx, t.Vault, t.Adapter) + if lerr != nil { + s.log.Error(lerr, "offer: liquidity/exposure", "adapter", t.Adapter.Hex()) + continue + } + s.log.V(1).Info("adapter liquidity", + "adapter", t.Adapter.Hex(), "fundable", st.fundable.String(), "outstanding", st.outstanding.String(), + "openLoans", st.openCount, "perRequestMax", st.perRequestMax.String(), "totalMax", st.totalMax.String(), + "minYieldBps", st.minYieldBps.String(), "maxConcurrent", st.maxConcurrent) + offerings = append(offerings, &adapterOffering{target: t, st: st, committed: new(big.Int)}) } - s.log.V(1).Info("target liquidity", - "adapter", target.Adapter.Hex(), "vault", target.Vault.Hex(), - "fundable", st.fundable.String(), "outstanding", st.outstanding.String(), "openLoans", st.openCount, - "perRequestMax", st.perRequestMax.String(), "totalMax", st.totalMax.String(), - "minYieldBps", st.minYieldBps.String(), "maxConcurrent", st.maxConcurrent) - - committed := new(big.Int) - opened := 0 + if len(offerings) == 0 { + return // every adapter's liquidity read failed this pass + } + now := time.Now() - s.offers.pruneExpired(now) // drop expired offer-tracking entries so the map stays bounded + s.offers.pruneExpired(now) // keep the dedup map bounded for i := range auctions { - av := auctionView{auctions[i]} - auctionID := int64(av.dto.Id) + s.offerAuction(ctx, auctionView{auctions[i]}, offerings, now) + } +} - // Asset gate: the auction's deposit asset must equal this target vault's collateral. - if !av.matchesAsset(target.Collateral) { - s.log.V(1).Info("skip auction: deposit asset != target collateral", "auctionId", auctionID, - "depositAsset", av.depositAsset(), "collateral", target.Collateral.Hex()) - continue - } - // From here on the auction concerns this target, so decisions are logged at info for visibility. - if !av.isOpen() { - s.log.Info("skip auction: status not open/solvable", "auctionId", auctionID, "status", av.dto.Status) - continue - } +// offerAuction covers one auction's full requested amount in a single pass: greedily offer through the +// most-fundable eligible adapter, each offer sized to the uncovered remainder, until covered or no +// adapter can add more (a later pass retries). Coverage already held counts, so a fully-covered auction +// is skipped. One adapter per offer; no aggregation within an offer. +func (s *Solver) offerAuction(ctx context.Context, av auctionView, offerings []*adapterOffering, now time.Time) { + auctionID := int64(av.dto.Id) + if !av.isOpen() { + s.log.V(1).Info("skip auction: status not open/solvable", "auctionId", auctionID, "status", av.dto.Status) + return + } + request := av.requestAddr() + if request == (common.Address{}) { + s.log.V(1).Info("skip auction: missing/invalid requestId", "auctionId", auctionID, "requestId", av.dto.RequestId) + return + } + amountRequested := av.amountRequested() + if amountRequested == nil || amountRequested.Sign() <= 0 { + s.log.V(1).Info("skip auction: missing/invalid amountRequested", "auctionId", auctionID) + return + } + rateBps, rateOk := av.maxRateBps() + if !rateOk { + s.log.V(1).Info("skip auction: maxRate unresolved", "auctionId", auctionID) + return + } - request := av.requestAddr() - if request == (common.Address{}) { - s.log.Info("skip auction: missing/invalid requestId", "auctionId", auctionID, "requestId", av.dto.RequestId) - continue - } + // Remaining = requested minus what our live offers (this pass and prior passes) already cover. + remaining := new(big.Int).Sub(amountRequested, s.offers.liveCoverage(auctionID, now)) + if remaining.Sign() <= 0 { + s.log.V(1).Info("skip auction: already fully covered by live offers", "auctionId", auctionID) + return + } - // Skip auctions we already hold a live (unexpired) offer for. Expired entries fall through so - // we re-offer. - if s.offers.hasLive(auctionID, now) { - s.log.Info("skip auction: live offer already outstanding", "auctionId", auctionID) - continue + // tried bounds each adapter to one consideration per auction, so the loop terminates. + tried := make(map[common.Address]bool, len(offerings)) + for remaining.Sign() > 0 { + candidates := make([]adapterSizing, 0, len(offerings)) + byAdapter := make(map[common.Address]*adapterOffering, len(offerings)) + for _, off := range offerings { + if tried[off.target.Adapter] || !av.matchesAsset(off.target.Collateral) || + s.offers.hasLive(off.target.Adapter, auctionID, now) { + continue + } + // Floor enforced at selection, not at signing. + if off.st.minYieldBps.Sign() > 0 && rateBps < bpsToFloat(off.st.minYieldBps) { + s.log.V(1).Info("skip adapter: rate below its on-chain return floor", "auctionId", auctionID, + "adapter", off.target.Adapter.Hex(), "maxRateBps", rateBps, "minYieldBps", off.st.minYieldBps.String()) + continue + } + principal, ok := sizeOffer(sizeInputs{ + perRequestMax: off.st.perRequestMax, + fundable: new(big.Int).Sub(off.st.fundable, off.committed), + amountRequested: remaining, // size to the uncovered remainder, not the full ask + sleeveMax: off.st.totalMax, + outstanding: new(big.Int).Add(off.st.outstanding, off.committed), + openCount: off.st.openCount + off.opened, + maxConcurrent: off.st.maxConcurrent, + }) + if !ok { + continue + } + candidates = append(candidates, adapterSizing{target: off.target, principal: principal}) + byAdapter[off.target.Adapter] = off } - principal, ok := sizeOffer(sizeInputs{ - perRequestMax: st.perRequestMax, - fundable: new(big.Int).Sub(st.fundable, committed), - amountRequested: av.amountRequested(), - sleeveMax: st.totalMax, - outstanding: new(big.Int).Add(st.outstanding, committed), - openCount: st.openCount + opened, - maxConcurrent: st.maxConcurrent, - }) + best, ok := selectBestAdapter(candidates) if !ok { - s.log.Info("skip auction: not biddable under policy/liquidity", "auctionId", auctionID, - "request", request.Hex(), "fundableRemaining", new(big.Int).Sub(st.fundable, committed).String(), - "openLoans", st.openCount+opened, "maxConcurrent", st.maxConcurrent) - continue + s.log.V(1).Info("auction not fully covered this pass; will retry next pass", + "auctionId", auctionID, "uncovered", remaining.String()) + return } + off := byAdapter[best.target.Adapter] + tried[best.target.Adapter] = true - // The adapter configured for this target is the offer maker (validated via EIP-1271). - dto, bid, buildErr := s.buildSignedOffer(av, request, target.Adapter, principal, st.minYieldBps) + dto, buildErr := s.buildSignedOffer(av, request, best.target.Adapter, best.principal, rateBps) if buildErr != nil { - s.log.Error(buildErr, "offer: build", "request", request.Hex()) - continue - } - if !bid { - s.log.Info("skip auction: rate below on-chain return floor", "auctionId", auctionID, - "request", request.Hex(), "maxRateBps", av.maxRate(), "minYieldBps", st.minYieldBps.String()) + s.log.Error(buildErr, "offer: build", "auctionId", auctionID, "adapter", best.target.Adapter.Hex()) continue } if subErr := s.api.createOffer(ctx, dto); subErr != nil { - s.log.Error(subErr, "offer: submit", "request", request.Hex()) + s.log.Error(subErr, "offer: submit", "auctionId", auctionID, "adapter", best.target.Adapter.Hex()) continue } - committed.Add(committed, principal) - opened++ - // Record so we don't re-offer until this offer expires. + off.committed.Add(off.committed, best.principal) + off.opened++ if exp, perr := parseUnixTime(dto.Expiration); perr == nil { - s.offers.record(auctionID, exp) + s.offers.record(best.target.Adapter, auctionID, exp, best.principal) } - s.log.Info("offer submitted", - "request", request.Hex(), "principal", principal.String(), "expectedReturn", dto.ExpectedReturn) + remaining.Sub(remaining, best.principal) + s.log.Info("offer submitted", "auctionId", auctionID, "adapter", best.target.Adapter.Hex(), + "request", request.Hex(), "principal", best.principal.String(), + "expectedReturn", dto.ExpectedReturn, "uncovered", remaining.String()) } + s.log.V(1).Info("auction fully covered this pass", "auctionId", auctionID) } -// ensureOfferAddress makes the 3F-registered facilitator offer-address match our maker (the target -// adapter). The offer-address is a facilitator-level singleton — the reason this solver serves a -// single vault+adapter pair. Read/write failures are returned so the caller can degrade to -// redeem-only. -func (s *Solver) ensureOfferAddress(ctx context.Context) error { - desired := s.cfg.Target.Adapter - current, err := s.api.offerAddress(ctx) - if err != nil { - return errors.Errorf("read offer-address: %w", err) - } - if current == desired { - return nil - } - if err := s.api.setOfferAddress(ctx, desired); err != nil { - return errors.Errorf("set offer-address to %s: %w", desired.Hex(), err) - } - s.log.Info("registered facilitator offer-address", "offerAddress", desired.Hex(), "previous", current.Hex()) - return nil -} - -// redeemAll runs the redeemer for the configured target. +// redeemAll runs the redeemer for every matched adapter. func (s *Solver) redeemAll(ctx context.Context) { - s.redeemReady(ctx, s.cfg.Target) + for _, t := range s.cfg.Targets { + s.redeemReady(ctx, t) + } } -// reconcile reports the live open-position set — a stateless health/observability tick. +// reconcile reports each adapter's live open-position set — a stateless health/observability tick. func (s *Solver) reconcile(ctx context.Context) { - target := s.cfg.Target - st, err := s.reader.liquidityAndExposure(ctx, target.Vault, target.Adapter) - if err != nil { - s.log.Error(err, "reconcile", "adapter", target.Adapter.Hex()) - return + for _, t := range s.cfg.Targets { + st, err := s.reader.liquidityAndExposure(ctx, t.Vault, t.Adapter) + if err != nil { + s.log.Error(err, "reconcile", "adapter", t.Adapter.Hex()) + continue + } + s.log.Info("reconcile", "adapter", t.Adapter.Hex(), + "openLoans", st.openCount, "outstandingPrincipal", st.outstanding.String()) } - s.log.Info("reconcile", "adapter", target.Adapter.Hex(), - "openLoans", st.openCount, "outstandingPrincipal", st.outstanding.String()) } // nextNonce returns a strictly-increasing offer nonce. @@ -315,23 +304,43 @@ func (s *Solver) nextNonce() uint64 { return s.nonceSeq.Add(1) } -// resolveTarget reads the adapter's vault and the vault's collateral asset once at startup. Both are -// fixed for the adapter's lifetime, so config only carries the adapter address. On a read failure the -// fields stay zero (no auction matches; offers disabled) but redemption still runs off the adapter. -func (s *Solver) resolveTarget(ctx context.Context) { - t := &s.cfg.Target - vault, err := s.reader.adapterVault(ctx, t.Adapter) - if err != nil { - s.log.Error(err, "resolve adapter vault; will match no auctions until restart", "adapter", t.Adapter.Hex()) - return +// resolveTargets resolves every adapter's vault, collateral, and EIP-1271 signer at startup (two +// batched Multicalls via reader.resolveAdapters) and keeps only the adapters that resolved and have +// this solver as their on-chain offerSigner — the rest are dropped with a warning. If none remain, +// it returns a startup error. +func (s *Solver) resolveTargets(ctx context.Context) error { + adapters := make([]common.Address, len(s.cfg.Targets)) + for i := range s.cfg.Targets { + adapters[i] = s.cfg.Targets[i].Adapter } - t.Vault = vault - collateral, err := s.reader.vaultAsset(ctx, vault) + resolved, err := s.reader.resolveAdapters(ctx, adapters) if err != nil { - s.log.Error(err, "resolve collateral; will match no auctions until restart", "vault", vault.Hex()) - return + return err // whole-batch transport/RPC failure, not a per-adapter revert + } + + kept := make([]Target, 0, len(s.cfg.Targets)) + for i, t := range s.cfg.Targets { + r := resolved[i] + if r.err != nil { + s.log.Error(r.err, "skipping adapter: resolution failed", "adapter", t.Adapter.Hex()) + continue + } + if r.signer != s.signerAddr { + s.log.Info("skipping adapter: solver is not its EIP-1271 signer", + "adapter", t.Adapter.Hex(), + "want", s.signerAddr.Hex(), + "got", r.signer.Hex()) + continue + } + t.Vault, t.Collateral = r.vault, r.collateral + s.log.Info("resolved target", + "adapter", t.Adapter.Hex(), "vault", r.vault.Hex(), "collateral", r.collateral.Hex()) + kept = append(kept, t) } - t.Collateral = collateral - s.log.Info("resolved target", - "adapter", t.Adapter.Hex(), "vault", vault.Hex(), "collateral", collateral.Hex()) + + s.cfg.Targets = kept + if len(s.cfg.Targets) == 0 { + return errors.Errorf("no configured adapter passed startup validation (must resolve and have this solver as its EIP-1271 signer, want %s); see per-adapter warnings above", s.signerAddr.Hex()) + } + return nil } From 6d774f2a51f418282fddeff2926323505288cf10 Mon Sep 17 00:00:00 2001 From: oxsteins Date: Fri, 26 Jun 2026 15:17:49 +0530 Subject: [PATCH 02/50] feat(solver): log a solver's fatal error so a stopped solver is visible A solver whose Run returns a fatal error previously only propagated it as the top-level return; with several solvers sharing the process the healthy ones shut down cleanly while the failed one left no error in the structured logs. Log it, attributed to the solver, before returning. --- internal/solver/solver.go | 5 ++++- internal/solver/solver_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/solver/solver.go b/internal/solver/solver.go index cd540bb1..7db0d359 100644 --- a/internal/solver/solver.go +++ b/internal/solver/solver.go @@ -110,7 +110,10 @@ func Run(ctx context.Context, s Solver, log logr.Logger) error { log.Info("solver running") err := s.Run(ctx) if err != nil && !errors.Is(err, context.Canceled) { - return errors.Errorf("solver %q: %w", s.Name(), err) + wrapped := errors.Errorf("solver %q: %w", s.Name(), err) + // Attribute the failure to this solver in the structured logs; the returned error still drives exit. + log.Error(wrapped, "solver stopped with error") + return wrapped } log.Info("solver stopped") return nil diff --git a/internal/solver/solver_test.go b/internal/solver/solver_test.go index f9d23cb1..92f8a3f2 100644 --- a/internal/solver/solver_test.go +++ b/internal/solver/solver_test.go @@ -2,8 +2,10 @@ package solver import ( "context" + "strings" "testing" + "github.com/go-errors/errors" "github.com/go-logr/logr" "gopkg.in/yaml.v3" ) @@ -79,3 +81,25 @@ func TestRunTreatsCancellationAsClean(t *testing.T) { t.Fatalf("expected nil on cancellation, got %v", err) } } + +type failingSolver struct { + name string + err error +} + +func (f failingSolver) Name() string { return f.name } +func (f failingSolver) Run(context.Context) error { return f.err } + +func TestRunWrapsNonCancellationError(t *testing.T) { + sentinel := errors.New("startup failed") + err := Run(context.Background(), failingSolver{name: "3f", err: sentinel}, logr.Discard()) + if err == nil { + t.Fatal("expected a non-nil error") + } + if !errors.Is(err, sentinel) { + t.Fatalf("error should wrap the solver's error, got %v", err) + } + if !strings.Contains(err.Error(), `"3f"`) { + t.Fatalf("error should name the solver, got %v", err) + } +} From e065bdac53bb3cef6dd3fd30f6aee6595e82f060 Mon Sep 17 00:00:00 2001 From: oxsteins Date: Fri, 26 Jun 2026 15:17:49 +0530 Subject: [PATCH 03/50] docs(3f): multi-adapter design, plan, and README Update 3F-PLAN.md and the README 3F section for the multi-adapter, signed-payload model (adapter-as-facilitator, per-auction coverage, floor-at-selection), and add the implementation plan. --- README.md | 43 +- docs/3F-PLAN.md | 95 +++- .../plans/2026-06-25-3f-multi-adapter.md | 423 ++++++++++++++++++ 3 files changed, 525 insertions(+), 36 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-25-3f-multi-adapter.md diff --git a/README.md b/README.md index 5f4fbeb4..f63df68c 100644 --- a/README.md +++ b/README.md @@ -45,25 +45,30 @@ running them together is safe. Each entry's `config` block is typed and validate ### 3F Bridge Facilitator — `3f-bridge-facilitator` -Acts as a Bridge Facilitator in 3F's bridge-loan auctions, on top of a Symbiotic -`BridgeFacilitatorAdapter`: - -- **Discover** open auctions via the 3F API (matched to a target vault by deposit asset == collateral). -- **Price & size** an offer at the auction's `maxRate`, capped by fundable vault liquidity and curator - exposure (per-request / total-sleeve / max-concurrent). -- **Sign & submit** the offer (EIP-712), with the adapter as the on-chain maker (verified via EIP-1271 - against an owner-set offer-signer key). -- **Fund** a won loan just-in-time inside the adapter's consume callback (self-allocation from vault - liquidity), then **redeem** repaid loans permissionlessly — realizing principal + yield back to the - vault. - -Onboarding generates a 3F facilitator API key (EIP-712) and registers the adapter as the facilitator -offer-address. Because 3F allows exactly **one offer-address per facilitator**, this solver serves a -**single `vault` + `adapter` pair**. The on-chain `BridgeFacilitatorAdapter` lives in the sibling -`rfq` repo, consumed via `api/bindings/3f/`. Config block: `apiBaseUrl`, `apiKeyEnv`, `minReturnBps`, -`vault`, `adapter`, `exposure`, `intervals` — see -[`config/config.example.yaml`](config/config.example.yaml). Design, decisions, and the live TODO -list: [`docs/3F-PLAN.md`](docs/3F-PLAN.md). +Acts as a Bridge Facilitator in 3F's bridge-loan auctions, on top of one or more Symbiotic +`BridgeFacilitatorAdapter`s: + +- **Discover** open auctions via the 3F API. +- **Per-auction coverage** — among the configured adapters whose collateral matches the auction, size + each against its on-chain liquidity + exposure caps (per-request / total-sleeve / max-concurrent) and, + in a single pass, offer through as many (most-fundable first) as needed to cover the auction's full + requested amount. One adapter per offer, no aggregation within an offer; coverage already held counts, + so a fully-covered auction is never re-offered and any uncovered remainder is retried next pass. +- **Sign & submit** the offer (EIP-712) as a **signed payload** — no API key. The adapter is the on-chain + `maker`, and 3F authorizes offer create + list via the adapter's **EIP-1271 `isValidSignature`** (which + trusts this solver's signer); listing sends a signed `Authorization` header. +- **Fund** a won loan just-in-time inside the adapter's consume callback, then **redeem** repaid loans + permissionlessly — realizing principal + yield back to the vault. Redeem + reconcile run for every + matched adapter. + +This solver holds **no API key and registers no offer-address**: each adapter is deployed and registered +with 3F **as a facilitator by its vault creator**, who sets this solver's signer as the adapter's EIP-1271 +signer. At startup the solver resolves each adapter's vault/collateral and verifies on-chain that it is the +adapter's signer — dropping any it isn't, and shutting down if none match. The on-chain +`BridgeFacilitatorAdapter` lives in the sibling `rfq` repo, consumed via `api/bindings/3f/`. Config block: +`apiBaseUrl`, `adapters` (a whitelist; a dynamic "list public adapters" API replaces it later), +`intervals` — see [`config/3f.sepolia.example.yaml`](config/3f.sepolia.example.yaml). Design, decisions, +and the live TODO list: [`docs/3F-PLAN.md`](docs/3F-PLAN.md). ### RFQ Filler — `rfq-filler` diff --git a/docs/3F-PLAN.md b/docs/3F-PLAN.md index e9aaee89..5ed5c310 100644 --- a/docs/3F-PLAN.md +++ b/docs/3F-PLAN.md @@ -14,12 +14,14 @@ repo root) §4 for the functional blueprint of the 3F solver. ## 1. Scope -- **In scope:** the off-chain Go bot — auction discovery, offer pricing/sizing/signing, +- **In scope:** the off-chain Go bot, serving **multiple `BridgeFacilitatorAdapter`s** — auction + discovery, **per-auction multi-adapter coverage**, offer pricing/sizing/signing (signed payloads), on-chain reads for liquidity, position reconciliation, and redemption. -- **Out of scope:** the on-chain `BridgeFacilitatorAdapter` (Solidity). It lives in a - separate repo and is consumed here only via generated ABI bindings. -- **First target network:** 3F Sepolia dev (`chainId 11155111`), which has a live - deployment and a public-readable dev API. Mainnet config slots in later. +- **Out of scope:** the on-chain `BridgeFacilitatorAdapter` (Solidity, consumed via generated ABI + bindings) **and its 3F onboarding**. In the new model each adapter is deployed and registered with 3F + **as a facilitator by its own vault creator**, who then sets this solver's signer as the adapter's + **EIP-1271 signer**. The bot registers nothing with 3F and holds no API key. +- **First target network:** 3F Sepolia dev (`chainId 11155111`). Mainnet config slots in later. --- @@ -33,9 +35,11 @@ repo root) §4 for the functional blueprint of the 3F solver. | License | _TBD — not yet added_ | | Contract bindings | **abigen over vendored ABIs** in `api/abi/` (ABIs copied from `forge build` output, not hand-curated). `make refresh-abi` re-vendors from a Foundry `out/` dir; build stays hermetic off the committed ABIs. | | API client | **openapi-generator (Java)** over a vendored OpenAPI snapshot in `openapi/`. `make refresh-openapi` re-pulls the live spec. | -| Persistence | **Stateless + periodic on-chain resync.** No DB. Open positions come from `adapter.activeRequests()`; redemption readiness from `canWithdraw()`; auctions/offers from the 3F API. Optional live-log subscription is a latency optimization only, never on the critical path. | -| Key management | Env/file private key behind a pluggable **`Signer`** interface (KMS/remote-signer can be added later without touching call sites). | -| Multi-solver shape | 3F logic fully encapsulated in its own package; `main` initializes one solver today. A name→factory **registry** selects the impl from config. A **shared `txmanager`** owns on-chain sending so solvers never race on nonces. | +| 3F auth | **Signed payloads — no API key, no offer-address.** Offer create + list authenticate via the EIP-712 offer signature, which 3F verifies through the adapter's **EIP-1271 `isValidSignature`** (the adapter trusts this solver's signer). Auction listing is public. Replaces the old self-generated `x-api-key` + per-facilitator offer-address registration. | +| Adapter scope | One solver serves a **set of adapters** (config whitelist now; a dynamic "list public 3F adapters" API later). Per auction it covers the **full requested amount** with one or more single-adapter offers (most-fundable first), stopping once covered — **1 adapter per offer, no aggregation within an offer** (a single offer is never split across adapters). | +| Persistence | **Stateless + periodic on-chain resync.** No DB. Open positions come from `adapter.activeRequests()` (per adapter); redemption readiness from `canWithdraw()`; auctions/offers from the 3F API. Optional live-log subscription is a latency optimization only, never on the critical path. | +| Key management | Env/file private key behind a pluggable **`Signer`** interface (KMS/remote-signer can be added later without touching call sites). This key is the **EIP-1271 signer every served adapter trusts** (each adapter's owner sets it on-chain): it signs offers with `maker = adapter`, and the adapter's `isValidSignature` authorizes them. The same EOA is the tx-sender for `redeem` (via the shared `txmanager`). | +| Multi-solver shape | 3F logic fully encapsulated in its own package; a name→factory **registry** selects the impl from config. A **shared `txmanager`** owns on-chain sending so solvers never race on nonces. | --- @@ -134,7 +138,7 @@ Adding a future solver is a register + config switch, no framework edit. --- -## 6. Configuration +## 6. Configuration & per-offer adapter selection Two-stage decode keeps solver config encapsulated. The generic layer reads only `solver.name` to pick the impl and keeps `solver.config` as a deferred `yaml.Node`; @@ -142,20 +146,53 @@ the chosen solver decodes it into its own typed struct. ```yaml chain: { rpcUrl, chainId, rpcFallbackUrls?, wsUrl? } # rpcFallbackUrls: HTTP(S), tried on primary failure -signer: { keyEnv: SOLVER_PRIVATE_KEY } # or keystorePath + passphraseEnv +signer: { keyEnv: SOLVER_PRIVATE_KEY } # the EIP-1271 signer every served adapter trusts txManager: { confirmations: 2, maxFeeGwei, tipGwei } solver: name: 3f-bridge-facilitator # ← registry key: selects the impl config: # ← opaque to framework; typed by the 3F package apiBaseUrl: https://bf.dev.gcp.3f.xyz - # Single vault+adapter pair: 3F registers exactly one offer-address per facilitator. - vault: "0x…" - adapter: "0x…" # BridgeFacilitatorAdapter (single-vault by construction) - exposure: { perRequestMaxUsdc: "…", totalSleeveMaxUsdc: "…", maxConcurrentLoans: 10 } + # The adapters this solver maintains offers for. Each must already be registered with 3F as a + # facilitator by its vault creator, with this solver's signer set as the adapter's EIP-1271 signer. + # A config whitelist for now; a dynamic "list public 3F adapters" API replaces it later. + adapters: + - "0x…adapterA" + - "0x…adapterB" + redeemBatchSize: 10 # optional (default 10) + httpTimeout: 30s # optional intervals: { discover: 1h, redeemPoll: 5m, reconcile: 15m } ``` +`apiKeyEnv` and the single `adapter`/`vault`/`exposure` keys are **gone**: there is no API key, and each +adapter's **vault + collateral are resolved on-chain** (`adapter.vault()` / `vault.asset()`) and its +**exposure caps are read on-chain** (`perRequestMaxCollateral`, `totalMaxCollateral`, `maxConcurrentLoans`, +`minRequestYieldBps`) — config carries only the adapter addresses. + +### Per-auction adapter coverage (the new multi-adapter core) + +Each discover tick lists open auctions (public, unauthenticated), then for each auction covers its +**full requested amount** with one or more single-adapter offers, in a single pass: + +1. **Candidates** — the configured adapters that are **eligible to bid**: collateral (`vault.asset()`) + matches the auction's `depositAsset`, no live offer of ours already covers them on this auction, and + the auction's `maxRate` clears the adapter's on-chain return floor (`minRequestYieldBps`). The floor is + a selection filter, not a late signing-time check — a floor-failing adapter never competes. +2. **Capacity** — read each candidate's exposure/liquidity in one Multicall (`fundable`, + `outstandingPrincipal`, open-loan count, the four on-chain caps), then `sizeOffer()` it against the + uncovered remainder (per-request → fundable → sleeve → concurrency → remainder). +3. **Cover the remainder** — `remaining = amountRequested − liveCoverage(auction)` (coverage already + held from this and prior passes, summed across adapters). If `remaining ≤ 0` the auction is already + fully covered → skip it (no duplicate offers). Otherwise greedily pick the **most-fundable** eligible + adapter, size its offer to `remaining`, submit, subtract, and repeat until covered or no adapter can + add more (a later pass retries the rest). Each adapter offers at most once per auction. **1 adapter + per offer, no aggregation within an offer** — a single offer is never split across adapters, but an + auction's ask may be covered by several single-adapter offers. +4. **Sign + submit** — each offer is built with `maker = chosen adapter`, the EIP-712 digest signed with + the solver signer, and `createOffer`d as a **signed payload** (3F authorizes via the adapter's + EIP-1271). Dedup is keyed by **(adapter, auction)** and offers carry their **principal**, so coverage + is tracked across adapters and passes. + --- ## 7. Make-driven codegen @@ -185,11 +222,33 @@ Prerequisite (done). **`BridgeFacilitatorAdapter` contract** — built in the `r 2. **(done)** Core infra (solver-agnostic) — config (two-stage decode), chain primitives, signer, **txmanager (+5 tests)**, solver interface/registry/engine, observability, graceful shutdown. 3. **(done)** 3F solver (encapsulated) — API client (x-api-key auctions/offers), sizer (fundable-liquidity + curator exposure caps; Request authorization is the on-chain 3F whitelist), EIP-712 offer signing **+ golden-hash + apitypes parity test**, reconcile + redeemer (poll `canWithdraw` → pack `redeem` → txmanager), exposure / no-over-commit guards. Deltas tracked in §10. 4. **(done)** Packaging + verification — README/config docs; Sepolia-dev e2e (offers won + redeemed live); multi-stage non-root distroless Dockerfile + compose (`deploy/`, ~20 MB static CGO-free image). +5. **(done) Adapter-as-facilitator + signed payloads + multi-adapter.** The new model (§1, §2, §6), + implemented across the `bridgefacilitator` package: + - **Dropped the API key + offer-address.** `listOffers` is now a per-adapter **signed** query (EIP-712 + `GetOffers` in an `Authorization: Bearer` header); `createOffer` sends no `x-api-key`. Removed the + key-gen, `apiKeyEnv`, and the `ensureOfferAddress`/`setOfferAddress` onboarding. Onboarding (deploy + adapter → register with 3F → set this signer as EIP-1271 signer) is the vault creator's job. + - **`adapter` → `adapters[]`.** Config whitelist; each adapter's vault/collateral resolved once at + startup; on-chain EIP-1271 signer check drops any adapter this solver isn't authorised for + (fail-closed; zero remaining → startup shutdown). **No redeem-only mode** — with ≥1 matching adapter + the bot runs offers + redeems for the matched set; with none it shuts down. + - **Per-auction multi-adapter coverage** (§6): cover each auction's full requested amount with one or + more single-adapter offers (most-fundable first) in a single pass, gated on live coverage so a + fully-covered auction is never re-offered; uncovered remainder retries next pass. Offer dedup, + coverage, exposure, redeem, and reconcile all run per adapter. + - Tests: `selectBestAdapter`, per-(adapter,auction) dedup, `liveCoverage`, signed `listOffers` httptest, `authorizedSigner` + Multicall round-trip, EIP-712 `GetOffers` golden + apitypes cross-check. The `GetOffers` type string + and the signer's live-API acceptance are pinned by env-guarded live tests (§9). --- ## 9. Open items to confirm during implementation +- **Signed-payload API contract** — confirm with 3F the exact request shape for creating *and listing* + offers without an API key: how a list request is authenticated/scoped to an adapter (the signed payload), + and that 3F verifies offer creation via the adapter's EIP-1271 `isValidSignature`. +- **Dynamic "list public 3F adapters" API** — the endpoint that replaces the config whitelist (what + marks an adapter public/eligible, and how we filter to ones our signer is the EIP-1271 signer for). - Mainnet `RequestWhitelist` address and prod API base URL — supplied by 3F when prod lands. - Go module path (`github.com/symbioticfi/vault-solver` placeholder) — adjust to the real org. @@ -200,9 +259,11 @@ Prerequisite (done). **`BridgeFacilitatorAdapter` contract** — built in the `r Tracked TODOs and known gaps — each a scoped follow-up; none block release. **Deferred features / known gaps:** -- **Move exposure / risk params on-chain.** Today the caps (`perRequestMaxUsdc`, `totalSleeveMaxUsdc`, `maxConcurrentLoans`, `minReturnBps`) live in the bot config and are enforced only off-chain in `sizeOffer` — a buggy or rogue bot could exceed them. Hoisting them into the `BridgeFacilitatorAdapter` (e.g. owner-set caps re-checked in `onRequestConsumed`, mirroring the removed `requestMetadata` budget but at the adapter level) makes the limits trust-minimized and curator-governed; the bot's config caps then become a redundant client-side guard. Needs a contract change in the `rfq` repo + binding regen; the bot reads the on-chain caps instead of (or in addition to) config. -- **Offer pricing is naive.** The bot bids at the auction's current `maxRate`, then caps to exposure + fundable liquidity — it models no spread, risk-adjusted target rate, time-in-auction, or competing offers. A real quoting strategy (e.g. the RFQ solver's strategy logic) is the main follow-up; `buildSignedOffer` is the seam to extend, and `MinReturnBps` is the only knob today. -- **API key logged at debug (`V(1)`).** Convenience for out-of-band replay; it is a secret in logs — disable or scrub before production. +- **(done) Exposure / risk params are on-chain.** The caps (`perRequestMaxCollateral`, `totalMaxCollateral`, `maxConcurrentLoans`, `minRequestYieldBps`) now live on the `BridgeFacilitatorAdapter` and are read per-adapter via Multicall each discover tick (`chainreader.go`); the bot no longer carries config exposure caps. Trust-minimized + curator-governed, as planned. +- **Multi-maker offers.** An auction's ask is covered by **multiple single-adapter offers** (most-fundable first, sized to the uncovered remainder), but a **single** offer is still funded by one adapter. Splitting one offer across several makers (true aggregation) is deferred — needs multi-maker offer support on-chain. +- **Re-pricing live offers on rising yield.** An auction's `maxRate` can climb over time, so an auction infeasible now (below an adapter's `minRequestYieldBps`) becomes feasible later — handled, since infeasible auctions are never negatively cached and each pass re-evaluates. But a live offer placed at an earlier, lower rate is **not** re-priced upward while it stays live (dedup by `(adapter, auction)`); capturing the higher rate would need cancel/replace (depends on `OfferControllerCancelV1`, below). +- **Dynamic adapter discovery.** The adapter set is a config whitelist; the dynamic "list public 3F adapters" API (§9) replaces it later, filtered to adapters our signer is the EIP-1271 signer for. +- **Offer pricing is naive.** The bot bids at the auction's current `maxRate`, then caps to exposure + fundable liquidity — it models no spread, risk-adjusted target rate, time-in-auction, or competing offers. A real quoting strategy (e.g. the RFQ solver's strategy logic) is the main follow-up; `buildSignedOffer` is the seam to extend, and the adapter's on-chain `minRequestYieldBps` is the only floor today. - **Offer cancellation.** `OfferControllerCancelV1` not wired — needs offer-id↔auction state. Note `offerTTL` (30m) < `discover` (1h) leaves a no-offer gap each cycle; consider `offerTTL` ≥ the discover interval (dedup prevents redundant re-offers). - **WS live-log subscription** (`chain.wsUrl`) — config field present but unused; the poll-based reconcile/redeem path is sufficient for v0. diff --git a/docs/superpowers/plans/2026-06-25-3f-multi-adapter.md b/docs/superpowers/plans/2026-06-25-3f-multi-adapter.md new file mode 100644 index 00000000..e84c8162 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-3f-multi-adapter.md @@ -0,0 +1,423 @@ +# 3F Multi-Adapter (adapter-as-facilitator, signed payloads) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor the `bridgefacilitator` (3F) solver from a single registered facilitator (API key + offer-address) into a keyless, **multi-adapter** solver that maintains offers on behalf of any adapter whose **EIP-1271 signer** is our key, selecting the single best-fit adapter per auction. + +**Architecture:** The adapter contract *is* the 3F facilitator (registered by its vault creator, with our signer set as its EIP-1271 signer). We authenticate purely with signatures: offer **creation** carries the EIP-712 `Offer` signature (`maker = adapter`, verified on-chain via the adapter's `isValidSignature`); offer **listing** uses an EIP-712 `GetOffers` signature in an `Authorization: Bearer` header against `maker=`. One solver serves a config whitelist of adapters; per auction it picks the single adapter that can fund the largest amount within its on-chain exposure caps (1 adapter per offer, no aggregation). + +**Tech Stack:** Go 1.26, `go-errors`, `logr`, generated `api/threef` OpenAPI client, `api/bindings/3f/*` abigen bindings, `internal/{chain,signer,txmanager}` shared infra. Tests: `go test -race`, table-driven + golden EIP-712 + httptest. + +## Global Constraints + +- Toolchain pinned: `GOTOOLCHAIN=go1.26.4`. Match it. +- Errors: `github.com/go-errors/errors` (`errors.Errorf("...: %w")`, `errors.New`) — never `fmt.Errorf` (`forbidigo` enforces). +- Logging: `logr.Logger`, structured key/values; `V(1)` for debug. Never log a secret/signature except a documented `V(1)` line. +- Solvers never send transactions directly — build calldata, hand to the shared `txmanager`. +- All config comes from YAML; secrets via `*Env` indirection read with `os.Getenv` at point of use. No hardcoded addresses/URLs. +- Generated code (`api/threef`, `api/bindings/**`) is never hand-edited; regenerate via `make`. +- Gate (must stay green): `GOTOOLCHAIN=go1.26.4 golangci-lint run --fix && go build ./... && go test -race -cover ./... && golangci-lint run`. +- Keep `docs/3F-PLAN.md` in sync in the same change (CLAUDE.md plan rule). +- Commits: author `oxsteins`, **no** `Co-Authored-By` trailer. + +--- + +## Verified ground truth (read before starting) + +- **3F API (`openapi/3f-bf.openapi.json`):** `POST /v1/offer` — `x-api-key` *optional* → create is signed-only (the `Offer` EIP-712 signature in the DTO). `GET /v1/offer` — params `maker` (required), `chainId?`, `deadline?`, header `Authorization?` ("`Bearer ` for EIP-712 authenticated requests; `deadline` required when using it"). So **listing by `maker=` = `Authorization: Bearer ` + `deadline`**. +- **EIP-712 pattern:** `internal/solvers/bridgefacilitator/eip712.go`. `APIKeyDigest` (lines 124-134) is the template for `GetOffersDigest`: grunt domain `{name:"grunt-api", version:"1", chainId:1}` (no `verifyingContract`), `keccak256(0x1901 ‖ domainSeparator ‖ structHash)`. `OfferDigest` (maker=adapter) already exists and is unchanged. +- **Exposure is on-chain per adapter** (`chainreader.go` `exposureState`): `limitOf`, `totalAssets`, `outstandingPrincipal`, `activeRequests`, `withdrawable`, `perRequestMaxCollateral`, `totalMaxCollateral`, `minRequestYieldBps`, `maxConcurrentLoans`. `sizeOffer` (`sizer.go:24-47`) applies caps in order. **No config exposure.** +- **Current solver loop** (`solver.go`): `Run` (3 tickers) → `onboard` (REMOVE) → `discoverAndOffer`→`offerForTarget` (single Target) → `redeemAll` → `reconcile`. `resolveTarget` resolves the single adapter's vault/collateral. +- **Decisions (locked):** adapter selection = **most fundable** (largest `sizeOffer` result; rate is fixed per auction so this maximizes expected return); `GetOffers` type = **scaffold + golden-test vs the live API** (like `TestAPIKeyDigest_MatchesLiveAcceptedSignature`); **verify the EIP-1271 signer on-chain at startup** (skip/warn adapters where our signer isn't authorized); the in-progress uncommitted bf edits are stashed — start from the committed base. + +--- + +## File structure + +| File | Change | +|---|---| +| `config.go` | `adapter string` → `adapters []string`; `Config.Target` → `Config.Targets []Target`; require ≥1 adapter. (`apiKeyEnv` removed later, in Task 3, when the client that reads it is rewritten — keeps every commit green.) | +| `config_test.go` | adapters-list parsing, empty-list + zero-address rejection | +| `eip712.go` | add `GetOffersDigest(maker, deadline)` + `getOffersTypeString` | +| `eip712_test.go` | `GetOffers` golden hash + live-API parity test | +| `apiclient.go` | drop key-gen/`ensureKey`/`withAuth` retry/`offerAddress`/`setOfferAddress`; `listOffers(adapter)` → Bearer `GetOffers` sig; constructor drops `fallbackKey`/`facilitator` | +| `chainreader.go` | add `authorizedSigner(adapter)` read (EIP-1271 signer); exposure read already per-adapter | +| `offercache.go` | dedup key `(adapter, auctionID)` not `auctionID` | +| `selection.go` (new) | `selectBestAdapter` — most-fundable pick | +| `solver.go` | remove `onboard`/`ensureOfferAddress`; `resolveTargets` (all adapters) + startup signer verification; multi-adapter discover/redeem/reconcile loops | +| `config/3f.sepolia.example.yaml` | `adapters:` list; drop `apiKeyEnv` | +| `docs/3F-PLAN.md` | already updated; sync any deltas (§8 phases) | + +The adapter binding methods (`vault()`, `vault.asset()`, exposure getters, and the **authorized-signer getter**) live under `api/bindings/3f/adapter` — confirm exact names there before writing the on-chain reads (Task 4). + +--- + +## Task 1: Config — `adapters[]` list + +**Files:** +- Modify: `internal/solvers/bridgefacilitator/config.go` +- Test: `internal/solvers/bridgefacilitator/config_test.go` + +**Interfaces:** +- Produces: `Config.Targets []Target` (each `Target{Adapter}` with `Vault`/`Collateral` resolved later); `parseConfig(yaml.Node) (*Config, error)` requires ≥1 non-zero adapter; `Config.Target` is replaced by `Config.Targets`. **`Config.APIKeyEnv` stays for now** (removed in Task 3). + +- [ ] **Step 1: Write the failing test** — append to `config_test.go`: + +```go +func TestParseConfig_AdaptersList(t *testing.T) { + cfg, err := parse(t, minimalConfig+"adapters:\n - \"0x0000000000000000000000000000000000000042\"\n - \"0x0000000000000000000000000000000000000043\"\n") + if err != nil { + t.Fatalf("parseConfig: %v", err) + } + if len(cfg.Targets) != 2 || + cfg.Targets[0].Adapter != common.HexToAddress("0x0000000000000000000000000000000000000042") || + cfg.Targets[1].Adapter != common.HexToAddress("0x0000000000000000000000000000000000000043") { + t.Fatalf("targets = %+v", cfg.Targets) + } +} + +func TestParseConfig_RejectsEmptyAndZeroAdapters(t *testing.T) { + if _, err := parse(t, minimalConfig); err == nil { + t.Fatal("expected an error when no adapters are configured") + } + if _, err := parse(t, minimalConfig+"adapters:\n - \"0x0000000000000000000000000000000000000000\"\n"); err == nil { + t.Fatal("expected an error for a zero adapter address") + } +} +``` + +Check `config_test.go` for the existing `parse` helper and `minimalConfig` const; reuse them. `minimalConfig` must be reduced to just `apiBaseUrl` (no `adapter`). + +- [ ] **Step 2: Run to verify it fails** + +Run: `GOTOOLCHAIN=go1.26.4 go test -run TestParseConfig_Adapters ./internal/solvers/bridgefacilitator/` +Expected: FAIL (compile error: `cfg.Targets` undefined). + +- [ ] **Step 3: Edit `config.go`** — + - `rawConfig`: replace `Adapter string \`yaml:"adapter"\`` with `Adapters []string \`yaml:"adapters"\``. **Keep** `APIKeyEnv` (removed in Task 3). + - `Config`: replace `Target Target` with `Targets []Target`. **Keep** `APIKeyEnv`. + - Replace `parseTarget` with: + +```go +func parseTargets(raw rawConfig) ([]Target, error) { + if len(raw.Adapters) == 0 { + return nil, errors.New("at least one adapters entry is required") + } + targets := make([]Target, 0, len(raw.Adapters)) + for i, a := range raw.Adapters { + adapter, err := parseNonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") + if err != nil { + return nil, err + } + targets = append(targets, Target{Adapter: adapter}) + } + return targets, nil +} +``` + + - In `parseConfig`: replace `target, err := parseTarget(raw)` with `targets, err := parseTargets(raw)` and set `Targets: targets` (keep `APIKeyEnv: raw.APIKeyEnv`). Add `"strconv"` import. + +- [ ] **Step 4: Run to verify it passes** + +Run: `GOTOOLCHAIN=go1.26.4 go test -run TestParseConfig ./internal/solvers/bridgefacilitator/` +Expected: PASS (other tests/call sites referencing `cfg.Target` fail to compile — fix them to `cfg.Targets[0]`; the package must build. `APIKeyEnv` is untouched here, so the API-client/onboarding wiring still compiles.) + +- [ ] **Step 5: Commit** + +```bash +git add internal/solvers/bridgefacilitator/config.go internal/solvers/bridgefacilitator/config_test.go internal/solvers/bridgefacilitator/solver.go +git commit -m "feat(3f): config takes an adapters list" +``` + +--- + +## Task 2: `GetOffers` EIP-712 digest (scaffold + golden) + +**Files:** +- Modify: `internal/solvers/bridgefacilitator/eip712.go` +- Test: `internal/solvers/bridgefacilitator/eip712_test.go` + +**Interfaces:** +- Produces: `GetOffersDigest(maker common.Address, deadline *big.Int) common.Hash`. + +- [ ] **Step 1: Write the failing golden test** — append to `eip712_test.go`: + +```go +func TestGetOffersDigest_Golden(t *testing.T) { + maker := common.HexToAddress("0x0000000000000000000000000000000000000042") + got := GetOffersDigest(maker, big.NewInt(4102444800)).Hex() + // GOLDEN: recompute once with the apitypes cross-check (Step 3a) and paste the value here. + want := "0x0000000000000000000000000000000000000000000000000000000000000000" + if got != want { + t.Fatalf("digest = %s, want %s", got, want) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `GOTOOLCHAIN=go1.26.4 go test -run TestGetOffersDigest ./internal/solvers/bridgefacilitator/` +Expected: FAIL (`GetOffersDigest` undefined). + +- [ ] **Step 3: Implement** — append to `eip712.go` (modeled on `APIKeyDigest`): + +```go +// getOffersTypeHash is the EIP-712 type the maker signs to list its offers via the Authorization +// header. SCAFFOLD: the exact field set is verified against the live 3F API in +// TestGetOffersDigest_MatchesLiveAcceptedSignature; adjust the type string if the API rejects it. +var getOffersTypeHash = crypto.Keccak256Hash([]byte("GetOffers(address maker,uint256 deadline)")) + +// GetOffersDigest computes the EIP-712 digest signed for an authenticated GET /v1/offer (maker=adapter). +// Same grunt-api domain as APIKeyDigest (name/version/chainId=1, no verifyingContract). +func GetOffersDigest(maker common.Address, deadline *big.Int) common.Hash { + ds := crypto.Keccak256Hash( + apiKeyDomainTypeHash.Bytes(), + crypto.Keccak256([]byte(apiKeyDomainName)), + crypto.Keccak256([]byte(apiKeyDomainVersion)), + word(big.NewInt(apiKeyDomainChainID).Bytes()), + ) + sh := crypto.Keccak256Hash(getOffersTypeHash.Bytes(), word(maker.Bytes()), word(deadline.Bytes())) + return crypto.Keccak256Hash([]byte{0x19, 0x01}, ds.Bytes(), sh.Bytes()) +} +``` + +- [ ] **Step 3a: Pin the golden** — add an apitypes cross-check (mirror `TestOfferDigest_MatchesApitypes`): build the same digest via `signer/core/apitypes`, assert it equals `GetOffersDigest(...)`, and copy the value into `want` in Step 1's test. Also add a **live-API parity** test guarded behind the same env flag the existing `TestAPIKeyDigest_MatchesLiveAcceptedSignature` uses (a correctly-formed sig is accepted; an unauthorized maker returns 403, not a signature error) — this is how the scaffolded type string is verified. + +- [ ] **Step 4: Run to verify it passes** + +Run: `GOTOOLCHAIN=go1.26.4 go test -run TestGetOffersDigest ./internal/solvers/bridgefacilitator/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/solvers/bridgefacilitator/eip712.go internal/solvers/bridgefacilitator/eip712_test.go +git commit -m "feat(3f): add GetOffers EIP-712 digest for signed offer listing" +``` + +--- + +## Task 3: API client — signed `listOffers(adapter)`, drop key + offer-address + +**Files:** +- Modify: `internal/solvers/bridgefacilitator/apiclient.go`, `internal/solvers/bridgefacilitator/config.go` (remove `APIKeyEnv` now — deferred from Task 1), `internal/solvers/bridgefacilitator/solver.go` (factory: drop the `os.Getenv(cfg.APIKeyEnv)` wiring into `newAPIClient`) +- Test: `internal/solvers/bridgefacilitator/liveauth_test.go` (or a new httptest in `apiclient_test.go`) + +**Interfaces:** +- Consumes: `GetOffersDigest` (Task 2); `signer.Signer.SignHash` (65-byte `[R‖S‖V]`). +- Produces: `(*apiClient).listOffers(ctx, adapter common.Address) ([]threef.OfferDto, error)`; `newAPIClient` no longer takes/needs an API key or facilitator address. + +- [ ] **Step 1: Write the failing test** — httptest asserting the request carries `maker=`, a `deadline`, and an `Authorization: Bearer 0x...` header, and NO `x-api-key`: + +```go +func TestAPIClient_ListOffers_SignedPerAdapter(t *testing.T) { + var gotMaker, gotAuth, gotKey, gotDeadline string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMaker = r.URL.Query().Get("maker") + gotDeadline = r.URL.Query().Get("deadline") + gotAuth = r.Header.Get("Authorization") + gotKey = r.Header.Get("x-api-key") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + adapter := common.HexToAddress("0x0000000000000000000000000000000000000042") + ac := newAPIClient(srv.URL, fakeSigner{}, 5*time.Second, logr.Discard()) + if _, err := ac.listOffers(context.Background(), adapter); err != nil { + t.Fatalf("listOffers: %v", err) + } + if gotMaker != lowerAddr(adapter) || gotDeadline == "" || + !strings.HasPrefix(gotAuth, "Bearer 0x") || gotKey != "" { + t.Fatalf("maker=%q deadline=%q auth=%q key=%q", gotMaker, gotDeadline, gotAuth, gotKey) + } +} +``` + +Add a `fakeSigner` test double (`Address()`, `SignHash([]byte)→65 bytes`, `SignTx` unused) if one isn't already in the package's test helpers — check `eip712_test.go`/existing tests first. + +- [ ] **Step 2: Run to verify it fails** + +Run: `GOTOOLCHAIN=go1.26.4 go test -run TestAPIClient_ListOffers ./internal/solvers/bridgefacilitator/` +Expected: FAIL (signature mismatch / `newAPIClient` arity). + +- [ ] **Step 3: Implement** — + - **Remove `APIKeyEnv`** (deferred from Task 1): delete it from `rawConfig`, `Config`, and the `parseConfig` literal in `config.go`; in `solver.go`'s factory, drop the `os.Getenv(cfg.APIKeyEnv)` read and stop passing a key into `newAPIClient`. + - In `apiclient.go`: **delete** `fallbackKey`, `apiKey`, `lastGenerate`, `facilitator` from the `apiClient` struct; delete `ensureKey`, `refreshKey`, `generate`, `withAuth`, `offerAddress`, `setOfferAddress`, `keyRegenCooldown`. Keep `sgnr signer.Signer`, `log`, the generated client `c`. + - Update `newAPIClient(baseURL string, sgnr signer.Signer, timeout time.Duration, log logr.Logger) *apiClient`. + - Rewrite `listOffers` to sign + use the `Authorization`/`deadline` builder params (confirm the generated builder method names on `OfferControllerGetV1` — the spec has `Authorization` + `deadline` + `maker`, so the builder should expose `.Authorization(...)`, `.Deadline(...)`, `.Maker(...)`): + +```go +const getOffersDeadlineWindow = 5 * time.Minute + +func (ac *apiClient) listOffers(ctx context.Context, adapter common.Address) ([]threef.OfferDto, error) { + deadline := big.NewInt(time.Now().Add(getOffersDeadlineWindow).Unix()) + sig, err := ac.sgnr.SignHash(GetOffersDigest(adapter, deadline)) + if err != nil { + return nil, errors.Errorf("3f api: sign GetOffers: %w", err) + } + o, httpResp, e := ac.c.OfferAPI.OfferControllerGetV1(ctx). + Maker(lowerAddr(adapter)). + Deadline(deadline.String()). + Authorization("Bearer 0x" + common.Bytes2Hex(sig)). + Execute() + closeResp(httpResp) + if e != nil { + _, err := handleApiError("3f api: list offers", httpResp, e) + return nil, errors.Errorf("3f api: list offers: %w", err) + } + return o, nil +} +``` + + - `createOffer` already has no `x-api-key` in scope after the key removal — confirm its builder chain doesn't call `.XApiKey(...)`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `GOTOOLCHAIN=go1.26.4 go test -run TestAPIClient ./internal/solvers/bridgefacilitator/` +Expected: PASS. The package will not fully build until `solver.go` stops calling the deleted onboarding funcs — fix those call sites in Task 4/8 (or temporarily stub `onboard` to a no-op to keep the build green between commits; remove it in Task 8). + +- [ ] **Step 5: Commit** + +```bash +git add internal/solvers/bridgefacilitator/apiclient.go internal/solvers/bridgefacilitator/*_test.go +git commit -m "feat(3f): list offers per-adapter via signed Authorization header" +``` + +--- + +## Task 4: Startup — resolve all adapters + verify EIP-1271 signer on-chain + +**Files:** +- Modify: `internal/solvers/bridgefacilitator/solver.go`, `internal/solvers/bridgefacilitator/chainreader.go` +- Test: `internal/solvers/bridgefacilitator/chainreader_test.go` + +**Interfaces:** +- Consumes: the adapter binding's `vault()`, vault `asset()`, and the **authorized-signer getter** (confirm name in `api/bindings/3f/adapter`). +- Produces: `(*reader).resolveTargets(ctx, []Target) ([]Target, error)` (fills `Vault`/`Collateral`); `(*reader).authorizedSigner(ctx, adapter) (common.Address, error)`; `Solver` startup filters `Targets` to those whose `authorizedSigner == deps.Signer.Address()`, warning on the rest. + +- [ ] **Step 1: Write the failing test** — table test for `authorizedSigner` over a Multicall3 fake backend (mirror existing `chainreader_test.go` setup): given an adapter whose on-chain signer == X, `authorizedSigner` returns X. + +- [ ] **Step 2: Run to verify it fails.** + +- [ ] **Step 3: Implement** — + - `chainreader.go`: add `authorizedSigner(ctx, adapter)` packing the binding's signer-getter call through `chain.Multicall` (single call) and decoding via the generated `Unpack`. Generalize the existing single-adapter vault/collateral resolution into `resolveTargets(ctx, targets)` (one Multicall for all adapters' `vault()`, then one for all `vault.asset()`). + - `solver.go`: replace `resolveTarget` with a startup block that calls `resolveTargets`, then `authorizedSigner` per adapter; drop any adapter where the signer ≠ `deps.Signer.Address()` with `log.Info("skipping adapter: solver is not its EIP-1271 signer", "adapter", a, "want", ourAddr, "got", onchain)`. If zero remain → return a startup error. + +- [ ] **Step 4: Run to verify it passes.** + +- [ ] **Step 5: Commit** + +```bash +git commit -am "feat(3f): resolve all adapters at startup and verify EIP-1271 signer on-chain" +``` + +--- + +## Task 5: Best-adapter selection (most fundable) + +**Files:** +- Create: `internal/solvers/bridgefacilitator/selection.go` +- Test: `internal/solvers/bridgefacilitator/selection_test.go` + +**Interfaces:** +- Consumes: `sizeOffer(...)` (`sizer.go`) and the per-adapter `exposureState` (`chainreader.go`). +- Produces: `selectBestAdapter(candidates []adapterSizing) (adapterSizing, bool)` where `adapterSizing` pairs a `Target` with its `sizeOffer` result for the auction; returns the max-sized candidate (`false` if none size > 0). + +- [ ] **Step 1: Write the failing test** — three candidates with sized amounts 100, 300, 200 → returns the 300 one; all-zero → `ok == false`; tie → deterministic (first by config order). + +- [ ] **Step 2: Run to verify it fails.** + +- [ ] **Step 3: Implement** `selectBestAdapter` — iterate, track the max `sized` (`*big.Int`), break ties by lowest index (config order). Pure function, no I/O. + +- [ ] **Step 4: Run to verify it passes.** + +- [ ] **Step 5: Commit** + +```bash +git commit -am "feat(3f): select the most-fundable adapter per auction" +``` + +--- + +## Task 6: Multi-adapter offer loop + per-(adapter,auction) dedup + +**Files:** +- Modify: `internal/solvers/bridgefacilitator/solver.go`, `internal/solvers/bridgefacilitator/offercache.go` +- Test: `internal/solvers/bridgefacilitator/offercache_test.go`, `solver_test.go` + +**Interfaces:** +- Consumes: `selectBestAdapter` (Task 5), `listOffers(ctx, adapter)` (Task 3), `buildSignedOffer(..., maker=adapter, ...)` (unchanged), `createOffer` (unchanged). +- Produces: `(*offerTracker)` keyed by `(adapter, auctionID)`; `discoverAndOffer` that, per open auction, reads each candidate adapter's exposure, sizes, selects best, signs `maker=best.Adapter`, submits, and records dedup under `(best.Adapter, auctionID)`. + +- [ ] **Step 1: Write the failing test** — `offercache_test.go`: an offer recorded for `(adapterA, auction1)` does NOT suppress an offer for `(adapterB, auction1)`, and DOES suppress a re-offer for `(adapterA, auction1)` until expiry. + +- [ ] **Step 2: Run to verify it fails.** + +- [ ] **Step 3: Implement** — + - `offercache.go`: change the `expiry` map key from `auctionID` to a `struct{ adapter common.Address; auction string }` (or `adapter.Hex()+"|"+auctionID`). Update `record`/`isLive`/rebuild signatures to take the adapter. + - `solver.go`: rewrite `discoverAndOffer`/`offerForTarget` into: list auctions once; for each open auction matching *any* candidate's `Collateral`, gather `adapterSizing` for the matching candidates (read each adapter's exposure — reuse the existing multicall per adapter), `selectBestAdapter`, skip if none or below `minRequestYieldBps`, `buildSignedOffer(maker=best.Adapter)`, `createOffer`, record dedup. Rebuild the dedup cache at startup by calling `listOffers` **per adapter** (Task 3) and recording `(adapter, auctionID)` for each live offer. + +- [ ] **Step 4: Run to verify it passes** (`solver_test.go` with fakes for the API + chain reader). + +- [ ] **Step 5: Commit** + +```bash +git commit -am "feat(3f): per-auction adapter selection, offers keyed by (adapter, auction)" +``` + +--- + +## Task 7: Multi-adapter redeem + reconcile + +**Files:** +- Modify: `internal/solvers/bridgefacilitator/solver.go`, `internal/solvers/bridgefacilitator/redeemer.go` + +**Interfaces:** +- Produces: `redeemAll` / `reconcile` iterate over all resolved `Targets` (each adapter's `activeRequests`→`canWithdraw`→`redeem`; each adapter's health snapshot). + +- [ ] **Step 1: Write the failing test** — `redeemer` with two adapters, each with one ready request, packs two `redeem` calldatas (one per adapter) handed to the fake txmanager. + +- [ ] **Step 2: Run to verify it fails.** + +- [ ] **Step 3: Implement** — wrap the existing single-adapter `redeemAll`/`reconcile` bodies in a `for _, tgt := range s.cfg.Targets` loop; the per-adapter reads/packing are unchanged. The redeem batch cap (`RedeemBatchSize`) applies per adapter per tick. + +- [ ] **Step 4: Run to verify it passes.** + +- [ ] **Step 5: Commit** + +```bash +git commit -am "feat(3f): redeem and reconcile across all configured adapters" +``` + +--- + +## Task 8: Remove onboarding; config example + docs + +**Files:** +- Modify: `internal/solvers/bridgefacilitator/solver.go` (delete `onboard`, `ensureOfferAddress`), `config/3f.sepolia.example.yaml`, `docs/3F-PLAN.md`, `README.md` + +**Interfaces:** none new — pure removal + docs. + +- [ ] **Step 1: Delete** `onboard` and `ensureOfferAddress` from `solver.go` and their call in `Run`. Replace the startup `onboard(ctx)` call with the Task-4 resolve+verify block and a `rebuildOfferCache` that lists per adapter (Task 6). +- [ ] **Step 2: Update** `config/3f.sepolia.example.yaml` — replace `adapter:`/`apiKeyEnv:` with an `adapters:` list; note onboarding (deploy + 3F register + set EIP-1271 signer) is the vault creator's job. +- [ ] **Step 3: Sync** `docs/3F-PLAN.md` §8 — mark Phase 5 items done; `README.md` 3F section — adapters list, no API key/offer-address. +- [ ] **Step 4: Run the full gate** + +Run: `GOTOOLCHAIN=go1.26.4 golangci-lint run --fix && go build ./... && go test -race -cover ./... && golangci-lint run` +Expected: all green, `bridgefacilitator` coverage not regressed. + +- [ ] **Step 5: Commit** + +```bash +git commit -am "feat(3f): drop API-key/offer-address onboarding; update config + docs" +``` + +--- + +## Self-review notes (gaps to confirm during implementation) + +- **`GetOffers` type string + `Authorization` format** — scaffolded as `GetOffers(address maker,uint256 deadline)` + `Bearer 0x<65-byte sig>`. Verify against the live 3F dev API (Task 2 Step 3a). If the API expects a different field set (e.g. includes `chainId`) or a non-`Bearer` scheme, adjust the type string / header in Tasks 2-3 only. +- **Generated builder method names** (`OfferControllerGetV1(...).Authorization/.Deadline/.Maker`) — confirm in `api/threef`; if the spec param names differ, regenerate is not needed (they're already in the vendored spec), just use the actual method names. +- **Adapter authorized-signer getter** — confirm the exact view name in `api/bindings/3f/adapter` (Task 4); if the adapter exposes only `isValidSignature`, verify by signing a probe digest and checking the magic value instead of reading a getter. +- **No aggregation** — one adapter per offer is enforced by `selectBestAdapter` returning a single candidate; do not sum across adapters. From 804f76246d3efa0b0829dce2dc377d041bdb61e1 Mon Sep 17 00:00:00 2001 From: oxsteins Date: Fri, 26 Jun 2026 15:36:38 +0530 Subject: [PATCH 04/50] refactor(3f): select offers in one ranked pass over adapter capacity Replace offerAuction's re-size-every-round greedy loop with a single pass: sizeOffer now returns each eligible adapter's capacity (no longer clamped to the ask), and selectOffers ranks candidates by capacity and assigns each the principal to offer (min(capacity, uncovered)) until the auction's remainder is filled. offerAuction just submits the returned offers. Drops the per-round candidate rebuild, the byAdapter map, and the tried set; gives the future min-amount exposure param a single home. --- docs/3F-PLAN.md | 21 +++-- .../solvers/bridgefacilitator/selection.go | 48 +++++++--- .../bridgefacilitator/selection_test.go | 58 +++++++++--- internal/solvers/bridgefacilitator/sizer.go | 22 +++-- .../solvers/bridgefacilitator/sizer_test.go | 19 ++-- internal/solvers/bridgefacilitator/solver.go | 89 ++++++++----------- 6 files changed, 145 insertions(+), 112 deletions(-) diff --git a/docs/3F-PLAN.md b/docs/3F-PLAN.md index 5ed5c310..45b288e0 100644 --- a/docs/3F-PLAN.md +++ b/docs/3F-PLAN.md @@ -179,15 +179,18 @@ Each discover tick lists open auctions (public, unauthenticated), then for each the auction's `maxRate` clears the adapter's on-chain return floor (`minRequestYieldBps`). The floor is a selection filter, not a late signing-time check — a floor-failing adapter never competes. 2. **Capacity** — read each candidate's exposure/liquidity in one Multicall (`fundable`, - `outstandingPrincipal`, open-loan count, the four on-chain caps), then `sizeOffer()` it against the - uncovered remainder (per-request → fundable → sleeve → concurrency → remainder). -3. **Cover the remainder** — `remaining = amountRequested − liveCoverage(auction)` (coverage already - held from this and prior passes, summed across adapters). If `remaining ≤ 0` the auction is already - fully covered → skip it (no duplicate offers). Otherwise greedily pick the **most-fundable** eligible - adapter, size its offer to `remaining`, submit, subtract, and repeat until covered or no adapter can - add more (a later pass retries the rest). Each adapter offers at most once per auction. **1 adapter - per offer, no aggregation within an offer** — a single offer is never split across adapters, but an - auction's ask may be covered by several single-adapter offers. + `outstandingPrincipal`, open-loan count, the four on-chain caps), then `sizeOffer()` it to its + **capacity** — the max principal it can fund (per-request → fundable → sleeve → concurrency), + independent of the ask. +3. **Select offers** — `remaining = amountRequested − liveCoverage(auction)` (coverage already held from + this and prior passes, summed across adapters). If `remaining ≤ 0` the auction is already fully + covered → skip it (no duplicate offers). Otherwise `selectOffers` ranks the candidates by capacity + (largest first) and, in one shot, assigns each the principal it will offer — `min(capacity, + still-uncovered)` — until `remaining` is filled or candidates run out. Each adapter offers at most + once. **1 adapter per offer, no aggregation within an offer** — a single offer is never split across + adapters, but an auction's ask may be covered by several single-adapter offers; any uncovered + remainder (insufficient capacity, or a build/submit failure) is retried next pass. A future + min-amount exposure param would be enforced here. 4. **Sign + submit** — each offer is built with `maker = chosen adapter`, the EIP-712 digest signed with the solver signer, and `createOffer`d as a **signed payload** (3F authorizes via the adapter's EIP-1271). Dedup is keyed by **(adapter, auction)** and offers carry their **principal**, so coverage diff --git a/internal/solvers/bridgefacilitator/selection.go b/internal/solvers/bridgefacilitator/selection.go index f14b9bf7..86f085bf 100644 --- a/internal/solvers/bridgefacilitator/selection.go +++ b/internal/solvers/bridgefacilitator/selection.go @@ -1,22 +1,46 @@ package bridgefacilitator -import "math/big" +import ( + "math/big" + "sort" +) -// adapterSizing pairs an adapter candidate with the principal it can fund for one auction. +// adapterSizing is one eligible adapter and the maximum principal it can currently fund for an auction +// (its exposure/liquidity capacity, independent of the auction's requested amount). type adapterSizing struct { - target Target + off *adapterOffering + capacity *big.Int +} + +// adapterOffer is a selected offer: the adapter and the principal it will be asked to fund. +type adapterOffer struct { + off *adapterOffering principal *big.Int } -// selectBestAdapter returns the candidate that can fund the largest principal (which, at the auction's -// fixed rate, maximizes expected return). Ties keep the earlier candidate; ok is false when empty. -func selectBestAdapter(candidates []adapterSizing) (adapterSizing, bool) { - var best adapterSizing - found := false - for _, c := range candidates { - if !found || c.principal.Cmp(best.principal) > 0 { - best, found = c, true +// selectOffers chooses, in one shot, the offers that cover `remaining` of an auction. It ranks the +// candidates by capacity (largest first) and assigns each the principal it will offer — +// min(capacity, still-uncovered) — until the amount is covered or candidates run out. The returned +// principals sum to min(remaining, Σcapacity); one offer per adapter. A future min-amount exposure +// param would be enforced here. +func selectOffers(candidates []adapterSizing, remaining *big.Int) []adapterOffer { + ranked := append([]adapterSizing(nil), candidates...) + sort.SliceStable(ranked, func(i, j int) bool { + return ranked[i].capacity.Cmp(ranked[j].capacity) > 0 + }) + + left := new(big.Int).Set(remaining) + offers := make([]adapterOffer, 0, len(ranked)) + for _, c := range ranked { + if left.Sign() <= 0 { + break + } + principal := new(big.Int).Set(c.capacity) + if principal.Cmp(left) > 0 { + principal.Set(left) } + offers = append(offers, adapterOffer{off: c.off, principal: principal}) + left.Sub(left, principal) } - return best, found + return offers } diff --git a/internal/solvers/bridgefacilitator/selection_test.go b/internal/solvers/bridgefacilitator/selection_test.go index c731db0c..c3c3efeb 100644 --- a/internal/solvers/bridgefacilitator/selection_test.go +++ b/internal/solvers/bridgefacilitator/selection_test.go @@ -7,28 +7,58 @@ import ( "github.com/ethereum/go-ethereum/common" ) -func TestSelectBestAdapter(t *testing.T) { - cand := func(n byte, p int64) adapterSizing { - return adapterSizing{target: Target{Adapter: common.Address{n}}, principal: big.NewInt(p)} +func TestSelectOffers(t *testing.T) { + // cand builds a candidate adapter keyed by its first address byte with the given capacity. + cand := func(n byte, capacity int64) adapterSizing { + return adapterSizing{ + off: &adapterOffering{target: Target{Adapter: common.Address{n}}}, + capacity: big.NewInt(capacity), + } } + adapterOf := func(o adapterOffer) byte { return o.off.target.Adapter[0] } - t.Run("picks the largest principal", func(t *testing.T) { - best, ok := selectBestAdapter([]adapterSizing{cand(1, 100), cand(2, 300), cand(3, 200)}) - if !ok || best.target.Adapter != (common.Address{2}) || best.principal.Int64() != 300 { - t.Fatalf("best = %+v ok=%v, want adapter 0x02 / 300", best, ok) + t.Run("largest first, clamp the last to the remainder", func(t *testing.T) { + offers := selectOffers([]adapterSizing{cand(1, 50), cand(2, 80), cand(3, 30)}, big.NewInt(100)) + // ranked by capacity: 2(80), 1(50), 3(30). Fill 100: 2→80 (rem 20), 1→20 (rem 0); 3 unused. + if len(offers) != 2 { + t.Fatalf("want 2 offers, got %d", len(offers)) + } + if adapterOf(offers[0]) != 2 || offers[0].principal.Int64() != 80 { + t.Errorf("offer0 = adapter %d / %s, want 2 / 80", adapterOf(offers[0]), offers[0].principal) + } + if adapterOf(offers[1]) != 1 || offers[1].principal.Int64() != 20 { + t.Errorf("offer1 = adapter %d / %s, want 1 / 20", adapterOf(offers[1]), offers[1].principal) } }) - t.Run("no candidates", func(t *testing.T) { - if _, ok := selectBestAdapter(nil); ok { - t.Fatal("expected ok=false for no candidates") + t.Run("a single adapter covers a small request (highest capacity wins)", func(t *testing.T) { + offers := selectOffers([]adapterSizing{cand(1, 80), cand(2, 70)}, big.NewInt(10)) + if len(offers) != 1 || adapterOf(offers[0]) != 1 || offers[0].principal.Int64() != 10 { + t.Fatalf("want a single offer adapter 1 / 10, got %d offers (%+v)", len(offers), offers) + } + }) + + t.Run("partial coverage when total capacity is short of the request", func(t *testing.T) { + offers := selectOffers([]adapterSizing{cand(1, 30), cand(2, 20)}, big.NewInt(100)) + if len(offers) != 2 { + t.Fatalf("want 2 offers, got %d", len(offers)) + } + sum := new(big.Int).Add(offers[0].principal, offers[1].principal) + if sum.Int64() != 50 { // both offer their full capacity; 50 < 100 stays uncovered + t.Fatalf("sum = %s, want 50", sum) + } + }) + + t.Run("equal capacity keeps config order", func(t *testing.T) { + offers := selectOffers([]adapterSizing{cand(1, 50), cand(2, 50)}, big.NewInt(50)) + if len(offers) != 1 || adapterOf(offers[0]) != 1 { + t.Fatalf("tie should keep the first candidate (0x01), got %d offers (first %d)", len(offers), adapterOf(offers[0])) } }) - t.Run("ties keep config order", func(t *testing.T) { - best, ok := selectBestAdapter([]adapterSizing{cand(1, 200), cand(2, 200)}) - if !ok || best.target.Adapter != (common.Address{1}) { - t.Fatalf("tie should keep the first (0x01), got %v", best.target.Adapter) + t.Run("no candidates", func(t *testing.T) { + if offers := selectOffers(nil, big.NewInt(100)); len(offers) != 0 { + t.Fatalf("expected no offers, got %d", len(offers)) } }) } diff --git a/internal/solvers/bridgefacilitator/sizer.go b/internal/solvers/bridgefacilitator/sizer.go index 3dd89dc7..72725ebf 100644 --- a/internal/solvers/bridgefacilitator/sizer.go +++ b/internal/solvers/bridgefacilitator/sizer.go @@ -7,20 +7,21 @@ import ( // sizeInputs are the bounds that constrain how much principal the bot may offer for one Request. The // caps mirror the adapter's authoritative on-chain exposure limits (each 0 = disabled). type sizeInputs struct { - perRequestMax *big.Int // adapter perRequestMaxCollateral (0 = no limit) - fundable *big.Int // delegator-cap + vault-liquidity headroom (chain read) - amountRequested *big.Int // auction ask; nil if unknown - sleeveMax *big.Int // adapter totalMaxCollateral (0 = no limit) - outstanding *big.Int // live sleeve exposure (sum of open principals) + perRequestMax *big.Int // adapter perRequestMaxCollateral (0 = no limit) + fundable *big.Int // delegator-cap + vault-liquidity headroom (chain read) + sleeveMax *big.Int // adapter totalMaxCollateral (0 = no limit) + outstanding *big.Int // live sleeve exposure (sum of open principals) openCount int maxConcurrent int // adapter maxConcurrentLoans (0 = no limit) } -// sizeOffer returns the principal to offer and whether to bid at all. `fundable` is always a hard cap — -// committing more would make the just-in-time allocation inside the consume callback revert. The -// per-Request, sleeve, and concurrency caps apply only when set (0 = disabled). Request authorization -// is enforced on-chain by the 3F whitelist at consume time, so the bot applies only these risk caps. +// sizeOffer returns the maximum principal an adapter can fund for one Request — its capacity — and +// whether it can bid at all. `fundable` is always a hard cap (committing more would make the +// just-in-time allocation inside the consume callback revert); the per-Request, sleeve, and +// concurrency caps apply only when set (0 = disabled). The capacity is independent of the auction's +// ask — selectOffers clamps it to the still-uncovered amount. Request authorization is enforced +// on-chain by the 3F whitelist at consume time, so the bot applies only these risk caps. func sizeOffer(in sizeInputs) (*big.Int, bool) { if in.maxConcurrent > 0 && in.openCount >= in.maxConcurrent { return nil, false @@ -37,9 +38,6 @@ func sizeOffer(in sizeInputs) (*big.Int, bool) { } amount = minBig(amount, sleeveRoom) } - if in.amountRequested != nil && in.amountRequested.Sign() > 0 { - amount = minBig(amount, in.amountRequested) - } if amount.Sign() <= 0 { return nil, false } diff --git a/internal/solvers/bridgefacilitator/sizer_test.go b/internal/solvers/bridgefacilitator/sizer_test.go index 462e15cc..34bd8764 100644 --- a/internal/solvers/bridgefacilitator/sizer_test.go +++ b/internal/solvers/bridgefacilitator/sizer_test.go @@ -10,13 +10,12 @@ func bi(n int64) *big.Int { return big.NewInt(n) } func TestSizeOffer(t *testing.T) { base := func() sizeInputs { return sizeInputs{ - perRequestMax: bi(250_000), - fundable: bi(500_000), - amountRequested: bi(1_000_000), - sleeveMax: bi(1_000_000), - outstanding: bi(0), - openCount: 0, - maxConcurrent: 10, + perRequestMax: bi(250_000), + fundable: bi(500_000), + sleeveMax: bi(1_000_000), + outstanding: bi(0), + openCount: 0, + maxConcurrent: 10, } } @@ -44,12 +43,6 @@ func TestSizeOffer(t *testing.T) { wantOK: true, want: bi(100_000), }, - { - name: "amountRequested binds", - mutate: func(in *sizeInputs) { in.amountRequested = bi(50_000) }, - wantOK: true, - want: bi(50_000), - }, { name: "concurrency cap reached", mutate: func(in *sizeInputs) { in.openCount = 10 }, diff --git a/internal/solvers/bridgefacilitator/solver.go b/internal/solvers/bridgefacilitator/solver.go index 717facea..a2b25251 100644 --- a/internal/solvers/bridgefacilitator/solver.go +++ b/internal/solvers/bridgefacilitator/solver.go @@ -182,10 +182,11 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { } } -// offerAuction covers one auction's full requested amount in a single pass: greedily offer through the -// most-fundable eligible adapter, each offer sized to the uncovered remainder, until covered or no -// adapter can add more (a later pass retries). Coverage already held counts, so a fully-covered auction -// is skipped. One adapter per offer; no aggregation within an offer. +// offerAuction covers one auction's full requested amount in a single pass: it sizes every eligible +// adapter to its capacity, then selectOffers ranks them (largest first) and assigns each the principal +// to offer until the still-uncovered amount is filled. Coverage already held counts, so a fully-covered +// auction is skipped. One adapter per offer; no aggregation within an offer. Any amount left uncovered +// (not enough capacity, or a build/submit failure) is retried on a later pass. func (s *Solver) offerAuction(ctx context.Context, av auctionView, offerings []*adapterOffering, now time.Time) { auctionID := int64(av.dto.Id) if !av.isOpen() { @@ -215,68 +216,52 @@ func (s *Solver) offerAuction(ctx context.Context, av auctionView, offerings []* return } - // tried bounds each adapter to one consideration per auction, so the loop terminates. - tried := make(map[common.Address]bool, len(offerings)) - for remaining.Sign() > 0 { - candidates := make([]adapterSizing, 0, len(offerings)) - byAdapter := make(map[common.Address]*adapterOffering, len(offerings)) - for _, off := range offerings { - if tried[off.target.Adapter] || !av.matchesAsset(off.target.Collateral) || - s.offers.hasLive(off.target.Adapter, auctionID, now) { - continue - } - // Floor enforced at selection, not at signing. - if off.st.minYieldBps.Sign() > 0 && rateBps < bpsToFloat(off.st.minYieldBps) { - s.log.V(1).Info("skip adapter: rate below its on-chain return floor", "auctionId", auctionID, - "adapter", off.target.Adapter.Hex(), "maxRateBps", rateBps, "minYieldBps", off.st.minYieldBps.String()) - continue - } - principal, ok := sizeOffer(sizeInputs{ - perRequestMax: off.st.perRequestMax, - fundable: new(big.Int).Sub(off.st.fundable, off.committed), - amountRequested: remaining, // size to the uncovered remainder, not the full ask - sleeveMax: off.st.totalMax, - outstanding: new(big.Int).Add(off.st.outstanding, off.committed), - openCount: off.st.openCount + off.opened, - maxConcurrent: off.st.maxConcurrent, - }) - if !ok { - continue - } - candidates = append(candidates, adapterSizing{target: off.target, principal: principal}) - byAdapter[off.target.Adapter] = off + // Size every eligible adapter to its capacity (collateral match, no live offer of ours, rate clears + // its return floor). selectOffers ranks these and clamps each to the uncovered remainder. + candidates := make([]adapterSizing, 0, len(offerings)) + for _, off := range offerings { + if !av.matchesAsset(off.target.Collateral) || s.offers.hasLive(off.target.Adapter, auctionID, now) { + continue } - - best, ok := selectBestAdapter(candidates) + if off.st.minYieldBps.Sign() > 0 && rateBps < bpsToFloat(off.st.minYieldBps) { + s.log.V(1).Info("skip adapter: rate below its on-chain return floor", "auctionId", auctionID, + "adapter", off.target.Adapter.Hex(), "maxRateBps", rateBps, "minYieldBps", off.st.minYieldBps.String()) + continue + } + capacity, ok := sizeOffer(sizeInputs{ + perRequestMax: off.st.perRequestMax, + fundable: new(big.Int).Sub(off.st.fundable, off.committed), + sleeveMax: off.st.totalMax, + outstanding: new(big.Int).Add(off.st.outstanding, off.committed), + openCount: off.st.openCount + off.opened, + maxConcurrent: off.st.maxConcurrent, + }) if !ok { - s.log.V(1).Info("auction not fully covered this pass; will retry next pass", - "auctionId", auctionID, "uncovered", remaining.String()) - return + continue } - off := byAdapter[best.target.Adapter] - tried[best.target.Adapter] = true + candidates = append(candidates, adapterSizing{off: off, capacity: capacity}) + } - dto, buildErr := s.buildSignedOffer(av, request, best.target.Adapter, best.principal, rateBps) + for _, sel := range selectOffers(candidates, remaining) { + adapter := sel.off.target.Adapter + dto, buildErr := s.buildSignedOffer(av, request, adapter, sel.principal, rateBps) if buildErr != nil { - s.log.Error(buildErr, "offer: build", "auctionId", auctionID, "adapter", best.target.Adapter.Hex()) + s.log.Error(buildErr, "offer: build", "auctionId", auctionID, "adapter", adapter.Hex()) continue } if subErr := s.api.createOffer(ctx, dto); subErr != nil { - s.log.Error(subErr, "offer: submit", "auctionId", auctionID, "adapter", best.target.Adapter.Hex()) + s.log.Error(subErr, "offer: submit", "auctionId", auctionID, "adapter", adapter.Hex()) continue } - off.committed.Add(off.committed, best.principal) - off.opened++ + sel.off.committed.Add(sel.off.committed, sel.principal) + sel.off.opened++ if exp, perr := parseUnixTime(dto.Expiration); perr == nil { - s.offers.record(best.target.Adapter, auctionID, exp, best.principal) + s.offers.record(adapter, auctionID, exp, sel.principal) } - remaining.Sub(remaining, best.principal) - s.log.Info("offer submitted", "auctionId", auctionID, "adapter", best.target.Adapter.Hex(), - "request", request.Hex(), "principal", best.principal.String(), - "expectedReturn", dto.ExpectedReturn, "uncovered", remaining.String()) + s.log.Info("offer submitted", "auctionId", auctionID, "adapter", adapter.Hex(), + "request", request.Hex(), "principal", sel.principal.String(), "expectedReturn", dto.ExpectedReturn) } - s.log.V(1).Info("auction fully covered this pass", "auctionId", auctionID) } // redeemAll runs the redeemer for every matched adapter. From 031da90c5447f559cd05b0a5f0fab6df278ecd8c Mon Sep 17 00:00:00 2001 From: oxsteins Date: Fri, 26 Jun 2026 15:49:10 +0530 Subject: [PATCH 05/50] fix(3f): authenticate offer listing with the operating-chain grunt-api domain The signed GET /v1/offer requires chainId in the query: the 3F server rebuilds the grunt-api EIP-712 domain from it to verify the signature and routes the EIP-1271 check to that chain. Sign the GetOffers digest and send the query chainId with the bot's operating chain (1 on mainnet, 11155111 on Sepolia), not a hardcoded 1. Also surface the server's response body in API errors (the generated client's error is otherwise only the status line), and re-vendor the 3F OpenAPI spec (doc-only drift; the generated client is unaffected). --- .../solvers/bridgefacilitator/apiclient.go | 39 +++++++++++++------ .../bridgefacilitator/apiclient_test.go | 11 ++++-- internal/solvers/bridgefacilitator/eip712.go | 35 +++++++++-------- .../solvers/bridgefacilitator/eip712_test.go | 15 ++++--- .../bridgefacilitator/liveauth_test.go | 7 +++- internal/solvers/bridgefacilitator/solver.go | 2 +- openapi/3f-bf.openapi.json | 16 ++++---- 7 files changed, 77 insertions(+), 48 deletions(-) diff --git a/internal/solvers/bridgefacilitator/apiclient.go b/internal/solvers/bridgefacilitator/apiclient.go index c30e0e08..77d21198 100644 --- a/internal/solvers/bridgefacilitator/apiclient.go +++ b/internal/solvers/bridgefacilitator/apiclient.go @@ -24,21 +24,23 @@ const getOffersDeadlineWindow = 5 * time.Minute // // All methods are called from the single solver Run goroutine; no locking is required. type apiClient struct { - c *threef.APIClient - sgnr signer.Signer - log logr.Logger + c *threef.APIClient + sgnr signer.Signer + chainID *big.Int // operating chain; the grunt-api signing domain and the listOffers chainId query + log logr.Logger } -func newAPIClient(baseURL string, sgnr signer.Signer, timeout time.Duration, log logr.Logger) *apiClient { +func newAPIClient(baseURL string, sgnr signer.Signer, chainID *big.Int, timeout time.Duration, log logr.Logger) *apiClient { cfg := threef.NewConfiguration() cfg.Servers = threef.ServerConfigurations{{URL: baseURL}} // Bound every call; the generated client otherwise uses http.DefaultClient (no timeout) and a hung // request would stall the single solver loop, redemption scans included. cfg.HTTPClient = &http.Client{Timeout: timeout} return &apiClient{ - c: threef.NewAPIClient(cfg), - sgnr: sgnr, - log: log, + c: threef.NewAPIClient(cfg), + sgnr: sgnr, + chainID: chainID, + log: log, } } @@ -47,7 +49,7 @@ func (ac *apiClient) listAuctions(ctx context.Context) ([]threef.AuctionDto, err auctions, httpResp, err := ac.c.AuctionAPI.AuctionControllerListV1(ctx).Domain(true).Execute() closeResp(httpResp) if err != nil { - return nil, errors.Errorf("3f api: list auctions: %s: %w", statusOf(httpResp), err) + return nil, apiErr("list auctions", httpResp, err) } return auctions, nil } @@ -57,7 +59,7 @@ func (ac *apiClient) createOffer(ctx context.Context, dto threef.CreateOfferDto) _, httpResp, e := ac.c.OfferAPI.OfferControllerCreateV1(ctx).CreateOfferDto(dto).Execute() closeResp(httpResp) if e != nil { - return errors.Errorf("3f api: create offer: %s: %w", statusOf(httpResp), e) + return apiErr("create offer", httpResp, e) } return nil } @@ -66,18 +68,21 @@ func (ac *apiClient) createOffer(ctx context.Context, dto threef.CreateOfferDto) // GetOffers signature in the Authorization: Bearer header — no API key required. func (ac *apiClient) listOffers(ctx context.Context, adapter common.Address) ([]threef.OfferDto, error) { deadline := big.NewInt(time.Now().Add(getOffersDeadlineWindow).Unix()) - sig, err := ac.sgnr.SignHash(GetOffersDigest(adapter, deadline)) + 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.OfferControllerGetV1(ctx). Maker(lowerAddr(adapter)). + // chainId is the operating chain; the server rebuilds the grunt-api signing domain from it to + // verify the signature and routes the EIP-1271 check to that chain. + ChainId(float32(ac.chainID.Int64())). Deadline(deadline.String()). Authorization("Bearer 0x" + common.Bytes2Hex(sig)). Execute() closeResp(httpResp) if e != nil { - return nil, errors.Errorf("3f api: list offers: %s: %w", statusOf(httpResp), e) + return nil, apiErr("list offers", httpResp, e) } return o, nil } @@ -90,6 +95,18 @@ func closeResp(resp *http.Response) { } } +// apiErr wraps a failed 3F call with its HTTP status and the server's response body — the client's own +// error is only the status line, but 3F returns the validation detail in the body. +func apiErr(what string, resp *http.Response, err error) error { + var genErr *threef.GenericOpenAPIError + if errors.As(err, &genErr) { + if body := strings.TrimSpace(string(genErr.Body())); body != "" { + return errors.Errorf("3f api: %s: %s: %s: %w", what, statusOf(resp), body, err) + } + } + return errors.Errorf("3f api: %s: %s: %w", what, statusOf(resp), err) +} + // statusOf renders an HTTP response's status for error context ("no response" if there was none). func statusOf(resp *http.Response) string { if resp == nil { diff --git a/internal/solvers/bridgefacilitator/apiclient_test.go b/internal/solvers/bridgefacilitator/apiclient_test.go index c96f807f..cc77764c 100644 --- a/internal/solvers/bridgefacilitator/apiclient_test.go +++ b/internal/solvers/bridgefacilitator/apiclient_test.go @@ -5,6 +5,7 @@ import ( "math/big" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -26,10 +27,11 @@ func (fakeSigner) SignTx(tx *types.Transaction, _ *big.Int) (*types.Transaction, } func TestAPIClient_ListOffers_SignedPerAdapter(t *testing.T) { - var gotMaker, gotAuth, gotKey, gotDeadline string + var gotMaker, gotAuth, gotKey, gotDeadline, gotChainID string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMaker = r.URL.Query().Get("maker") gotDeadline = r.URL.Query().Get("deadline") + gotChainID = r.URL.Query().Get("chainId") gotAuth = r.Header.Get("Authorization") gotKey = r.Header.Get("x-api-key") w.Header().Set("Content-Type", "application/json") @@ -38,12 +40,13 @@ func TestAPIClient_ListOffers_SignedPerAdapter(t *testing.T) { defer srv.Close() adapter := common.HexToAddress("0x0000000000000000000000000000000000000042") - ac := newAPIClient(srv.URL, fakeSigner{}, 5*time.Second, logr.Discard()) + ac := newAPIClient(srv.URL, fakeSigner{}, big.NewInt(11155111), 5*time.Second, logr.Discard()) if _, err := ac.listOffers(context.Background(), adapter); err != nil { t.Fatalf("listOffers: %v", err) } - if gotMaker != lowerAddr(adapter) || gotDeadline == "" || + chainID, _ := strconv.ParseFloat(gotChainID, 64) // generated client serializes chainId as a float + if gotMaker != lowerAddr(adapter) || gotDeadline == "" || chainID != 11155111 || !strings.HasPrefix(gotAuth, "Bearer 0x") || gotKey != "" { - t.Fatalf("maker=%q deadline=%q auth=%q key=%q", gotMaker, gotDeadline, gotAuth, gotKey) + t.Fatalf("maker=%q chainId=%q deadline=%q auth=%q key=%q", gotMaker, gotChainID, gotDeadline, gotAuth, gotKey) } } diff --git a/internal/solvers/bridgefacilitator/eip712.go b/internal/solvers/bridgefacilitator/eip712.go index c11ed543..c215f1ee 100644 --- a/internal/solvers/bridgefacilitator/eip712.go +++ b/internal/solvers/bridgefacilitator/eip712.go @@ -104,10 +104,8 @@ func bpsToFloat(n *big.Int) float64 { // RateDenominatorBps converts a basis-point rate to a fraction (10_000 = 100%). const RateDenominatorBps = 10_000.0 -// API-key generation EIP-712, validated against the live 3F dev API (a correctly-formed signature -// is accepted; an un-onboarded facilitator returns 403, not a signature error). The domain omits -// verifyingContract and pins chainId = 1 even on testnets ("current implementation only accepts -// chainId = 1", per the spec). +// grunt-api EIP-712 domain (no verifyingContract). chainId is per-flow: the (test-only) API-key +// generation domain uses 1; the GetOffers listing domain uses the bot's operating chain. const ( apiKeyDomainName = "grunt-api" apiKeyDomainVersion = "1" @@ -121,27 +119,30 @@ var ( []byte("EIP712Domain(string name,string version,uint256 chainId)")) ) -// gruntAPIDomainSeparator is the EIP-712 domain separator shared by every grunt-api request -// (name/version/chainId=1, no verifyingContract). Computed once. -var gruntAPIDomainSeparator = crypto.Keccak256Hash( - apiKeyDomainTypeHash.Bytes(), - crypto.Keccak256([]byte(apiKeyDomainName)), - crypto.Keccak256([]byte(apiKeyDomainVersion)), - word(big.NewInt(apiKeyDomainChainID).Bytes()), -) +// gruntAPIDomainSeparator builds the grunt-api domain separator (name/version, no verifyingContract) +// for chainID; the 3F server rebuilds it from the request's chainId query param to verify the signature. +func gruntAPIDomainSeparator(chainID *big.Int) common.Hash { + return crypto.Keccak256Hash( + apiKeyDomainTypeHash.Bytes(), + crypto.Keccak256([]byte(apiKeyDomainName)), + crypto.Keccak256([]byte(apiKeyDomainVersion)), + word(chainID.Bytes()), + ) +} // getOffersTypeHash is the EIP-712 type the maker signs to list its offers via the Authorization // header; the field set is checked against the live 3F API in the GetOffers golden test. var getOffersTypeHash = crypto.Keccak256Hash([]byte("GetOffers(address maker,uint256 deadline)")) -// GetOffersDigest computes the EIP-712 digest signed for an authenticated GET /v1/offer (maker=adapter). -func GetOffersDigest(maker common.Address, deadline *big.Int) common.Hash { +// GetOffersDigest computes the EIP-712 digest signed for an authenticated GET /v1/offer (maker=adapter) +// over the grunt-api domain at chainID (the bot's operating chain). +func GetOffersDigest(maker common.Address, deadline, chainID *big.Int) common.Hash { sh := crypto.Keccak256Hash(getOffersTypeHash.Bytes(), word(maker.Bytes()), word(deadline.Bytes())) - return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator.Bytes(), sh.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. +// 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())) - return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator.Bytes(), sh.Bytes()) + return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator(big.NewInt(apiKeyDomainChainID)).Bytes(), sh.Bytes()) } diff --git a/internal/solvers/bridgefacilitator/eip712_test.go b/internal/solvers/bridgefacilitator/eip712_test.go index 20f44ba7..d74fad74 100644 --- a/internal/solvers/bridgefacilitator/eip712_test.go +++ b/internal/solvers/bridgefacilitator/eip712_test.go @@ -119,8 +119,8 @@ func TestAPIKeyDigest_MatchesLiveAcceptedSignature(t *testing.T) { func TestGetOffersDigest_Golden(t *testing.T) { maker := common.HexToAddress("0x0000000000000000000000000000000000000042") - got := GetOffersDigest(maker, big.NewInt(4102444800)).Hex() - // GOLDEN: pinned from TestGetOffersDigest_MatchesApitypes cross-check. + got := GetOffersDigest(maker, big.NewInt(4102444800), big.NewInt(apiKeyDomainChainID)).Hex() + // GOLDEN: pinned from TestGetOffersDigest_MatchesApitypes cross-check (chainId 1). want := "0x9d4c2e5ccaaeb6884d2d2fd8e306e57cf781ef424db9e8801c703eac794fa6a5" if got != want { t.Fatalf("digest = %s, want %s", got, want) @@ -134,7 +134,7 @@ func TestGetOffersDigest_MatchesApitypes(t *testing.T) { maker := common.HexToAddress("0x0000000000000000000000000000000000000042") deadline := big.NewInt(4102444800) - got := GetOffersDigest(maker, deadline) + got := GetOffersDigest(maker, deadline, big.NewInt(apiKeyDomainChainID)) typed := apitypes.TypedData{ Types: apitypes.Types{ @@ -192,8 +192,12 @@ func TestGetOffersDigest_MatchesLiveAcceptedSignature(t *testing.T) { } maker := crypto.PubkeyToAddress(key.PublicKey) deadline := big.NewInt(4_102_444_800) + chainID := big.NewInt(11155111) // Sepolia; the grunt-api domain + query chainId must agree + if v := os.Getenv("SOLVER_CHAIN_ID"); v != "" { + chainID, _ = new(big.Int).SetString(v, 10) + } - sig, err := crypto.Sign(GetOffersDigest(maker, deadline).Bytes(), key) + sig, err := crypto.Sign(GetOffersDigest(maker, deadline, chainID).Bytes(), key) if err != nil { t.Fatalf("sign: %v", err) } @@ -204,7 +208,8 @@ func TestGetOffersDigest_MatchesLiveAcceptedSignature(t *testing.T) { baseURL = "https://bf.dev.gcp.3f.xyz" } - url := fmt.Sprintf("%s/v1/offer?maker=%s&deadline=%s", baseURL, strings.ToLower(maker.Hex()), deadline.String()) + url := fmt.Sprintf("%s/v1/offer?maker=%s&chainId=%s&deadline=%s", + baseURL, strings.ToLower(maker.Hex()), chainID.String(), deadline.String()) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) //nolint:gosec // G704: URL is operator-supplied via SOLVER_3F_BASE_URL in this live integration test if err != nil { t.Fatalf("build request: %v", err) diff --git a/internal/solvers/bridgefacilitator/liveauth_test.go b/internal/solvers/bridgefacilitator/liveauth_test.go index a0ffb060..8c420b5c 100644 --- a/internal/solvers/bridgefacilitator/liveauth_test.go +++ b/internal/solvers/bridgefacilitator/liveauth_test.go @@ -2,6 +2,7 @@ package bridgefacilitator import ( "context" + "math/big" "os" "strings" "testing" @@ -33,7 +34,11 @@ func TestLiveListOffers(t *testing.T) { baseURL = "https://bf.dev.gcp.3f.xyz" } - ac := newAPIClient(baseURL, sgnr, 30*time.Second, logr.Discard()) + chainID := big.NewInt(11155111) // Sepolia; override for another chain + if v := os.Getenv("SOLVER_CHAIN_ID"); v != "" { + chainID, _ = new(big.Int).SetString(v, 10) + } + ac := newAPIClient(baseURL, sgnr, chainID, 30*time.Second, logr.Discard()) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/internal/solvers/bridgefacilitator/solver.go b/internal/solvers/bridgefacilitator/solver.go index a2b25251..9f585c36 100644 --- a/internal/solvers/bridgefacilitator/solver.go +++ b/internal/solvers/bridgefacilitator/solver.go @@ -44,7 +44,7 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { return nil, err } - api := newAPIClient(cfg.APIBaseURL, deps.Signer, cfg.HTTPTimeout, deps.Log.WithName(Name)) + api := newAPIClient(cfg.APIBaseURL, deps.Signer, deps.Chain.ChainID(), cfg.HTTPTimeout, deps.Log.WithName(Name)) s := &Solver{ cfg: cfg, diff --git a/openapi/3f-bf.openapi.json b/openapi/3f-bf.openapi.json index 4ca0a318..f337e847 100644 --- a/openapi/3f-bf.openapi.json +++ b/openapi/3f-bf.openapi.json @@ -146,7 +146,7 @@ }, "/v1/offer": { "post": { - "description": "Creates an offer for an auction, or updates the existing mutable offer for the same `auctionId`, `maker`, and `nonce`. If `signature` is provided, it is verified as an EIP-712 signature and the `maker` must be a registered facilitator. Contract wallets are supported via EIP-1271. If `signature` is omitted, a valid facilitator `x-api-key` header is required; when that facilitator has a configured offer address, that offer address is used as the stored `maker`.\n\n`expectedReturn` is the expected yield, not the total repayment. Total repayment is `amount + expectedReturn`.\n\n`expiration` is a Unix timestamp in seconds. Offers are expired only when `expiration` is lower than the current Unix second.\n\nSigned offer requests resolve their EIP-712 domain from the auction request contract on-chain. Set `domain.verifyingContract` to the request contract address for the selected auction. If the contract exposes a `salt`, include it; otherwise omit that field.\n\nExact typed data to sign with `viem`:\n\n```ts\nconst signature = await walletClient.signTypedData(\n{\n domain: {\n name: 'SuperstateRequest',\n version: '1',\n chainId: 11155111,\n verifyingContract: '0x1234567890abcdef1234567890abcdef12345678',\n salt: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\n },\n types: {\n Offer: [\n {\n name: 'maker',\n type: 'address',\n },\n {\n name: 'amount',\n type: 'uint256',\n },\n {\n name: 'expectedReturn',\n type: 'uint256',\n },\n {\n name: 'nonce',\n type: 'uint256',\n },\n {\n name: 'expiration',\n type: 'uint256',\n },\n {\n name: 'useCallback',\n type: 'bool',\n },\n ],\n },\n primaryType: 'Offer',\n message: {\n maker: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38',\n amount: 1000000000n,\n expectedReturn: 5000000n,\n nonce: 1n,\n expiration: 4102444800n,\n useCallback: false,\n },\n }\n)\n```\n\nSubmit the resulting signature in the request body `signature` field. All `uint256` request fields stay decimal strings in the HTTP payload.", + "description": "Creates an offer for an auction, or updates the existing mutable offer for the same `auctionId`, `maker`, and `nonce`. If `signature` is provided, the `maker` must be a registered facilitator address or that facilitator's configured offer address; signature executability is checked by the relayer before on-chain `consume`, so ERC-1271 approvals may become valid asynchronously. If `signature` is omitted, a valid facilitator `x-api-key` header is required; when that facilitator has a configured offer address, that offer address is used as the stored `maker`.\n\n`expectedReturn` is the expected yield, not the total repayment. Total repayment is `amount + expectedReturn`.\n\n`expiration` is a Unix timestamp in seconds. Offers are expired only when `expiration` is lower than the current Unix second.\n\nSigned offer requests resolve their EIP-712 domain from the auction request contract on-chain. Set `domain.verifyingContract` to the request contract address for the selected auction. If the contract exposes a `salt`, include it; otherwise omit that field.\n\nExact typed data to sign with `viem`:\n\n```ts\nconst signature = await walletClient.signTypedData(\n{\n domain: {\n name: 'SuperstateRequest',\n version: '1',\n chainId: 11155111,\n verifyingContract: '0x1234567890abcdef1234567890abcdef12345678',\n salt: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\n },\n types: {\n Offer: [\n {\n name: 'maker',\n type: 'address',\n },\n {\n name: 'amount',\n type: 'uint256',\n },\n {\n name: 'expectedReturn',\n type: 'uint256',\n },\n {\n name: 'nonce',\n type: 'uint256',\n },\n {\n name: 'expiration',\n type: 'uint256',\n },\n {\n name: 'useCallback',\n type: 'bool',\n },\n ],\n },\n primaryType: 'Offer',\n message: {\n maker: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38',\n amount: 1000000000n,\n expectedReturn: 5000000n,\n nonce: 1n,\n expiration: 4102444800n,\n useCallback: false,\n },\n }\n)\n```\n\nSubmit the signature bytes in the request body `signature` field. For deferred ERC-1271 approval, submit `0x` while the contract approval transaction is pending. All `uint256` request fields stay decimal strings in the HTTP payload.", "operationId": "OfferController_create_v1", "parameters": [ { @@ -720,7 +720,7 @@ "properties": { "chainId": { "type": "number", - "description": "Chain ID for signature verification", + "description": "Chain ID for resolving the request EIP-712 domain", "example": 1 }, "auctionId": { @@ -761,9 +761,8 @@ }, "signature": { "type": "string", - "pattern": "^0x[a-fA-F0-9]{130}$", - "description": "EIP-712 signature (required if chainId is provided)", - "example": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" + "description": "EIP-712/EIP-1271 signature bytes. Use `0x` while deferred EIP-1271 approval is pending. Required if chainId is provided.", + "example": "0x" } }, "required": [ @@ -815,7 +814,6 @@ }, "signature": { "type": "string", - "pattern": "^0x[a-fA-F0-9]{130}$", "description": "EIP-712 signature (required if chainId is provided)", "example": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" } @@ -1159,14 +1157,14 @@ ] }, "direction": { - "type": "string", + "nullable": true, "enum": [ "subscription", "redemption" ], + "type": "string", "description": "Auction direction derived from the first facility-intent operation, or null if no operation has been recorded yet", - "example": "subscription", - "nullable": true + "example": "subscription" }, "eip712Domain": { "nullable": true, From 50111132320a66a55aaa2f88edb191573903947f Mon Sep 17 00:00:00 2001 From: alrxy Date: Fri, 26 Jun 2026 18:58:41 +0700 Subject: [PATCH 06/50] chore: vendor RedStone OEV generated surfaces --- CLAUDE.md | 19 +- Makefile | 81 +- api/abi/AdaptiveCurveIrm.json | 70 + api/abi/AggregatorV3.json | 41 + api/abi/ERC20.json | 9 + api/abi/Morpho.json | 97 + api/abi/MorphoOracle.json | 13 + api/abi/RedStoneExecutor.json | 71 + api/abi/SymbioticOevSolver.json | 400 ++ api/bindings/erc20/ERC20.go | 86 + .../adapter/LiquidLaneAdapter.go | 0 api/bindings/multicall3/Multicall3.go | 200 +- api/bindings/oev/aggregator/AggregatorV3.go | 136 + .../oev/callback/SymbioticOevSolver.go | 858 +++ api/bindings/oev/executor/RedStoneExecutor.go | 220 + api/bindings/oev/irm/AdaptiveCurveIrm.go | 105 + api/bindings/oev/morpho/Morpho.go | 199 + api/bindings/oev/oracle/MorphoOracle.go | 86 + api/graphql/morpho/README.md | 27 + api/graphql/morpho/genqlient.yaml | 16 + api/graphql/morpho/operations.json | 14 + .../morpho/operations/discovery.graphql | 52 + api/graphql/morpho/schema.graphql | 5854 +++++++++++++++++ api/morphographql/generated.go | 420 ++ api/morphographql/scalars/scalars.go | 35 + go.mod | 4 +- go.sum | 12 +- openapi/redstone-oev-ws.zod.ts | 68 + 28 files changed, 9001 insertions(+), 192 deletions(-) create mode 100644 api/abi/AdaptiveCurveIrm.json create mode 100644 api/abi/AggregatorV3.json create mode 100644 api/abi/ERC20.json create mode 100644 api/abi/Morpho.json create mode 100644 api/abi/MorphoOracle.json create mode 100644 api/abi/RedStoneExecutor.json create mode 100644 api/abi/SymbioticOevSolver.json create mode 100644 api/bindings/erc20/ERC20.go rename api/bindings/{rfq => liquidlane}/adapter/LiquidLaneAdapter.go (100%) create mode 100644 api/bindings/oev/aggregator/AggregatorV3.go create mode 100644 api/bindings/oev/callback/SymbioticOevSolver.go create mode 100644 api/bindings/oev/executor/RedStoneExecutor.go create mode 100644 api/bindings/oev/irm/AdaptiveCurveIrm.go create mode 100644 api/bindings/oev/morpho/Morpho.go create mode 100644 api/bindings/oev/oracle/MorphoOracle.go create mode 100644 api/graphql/morpho/README.md create mode 100644 api/graphql/morpho/genqlient.yaml create mode 100644 api/graphql/morpho/operations.json create mode 100644 api/graphql/morpho/operations/discovery.graphql create mode 100644 api/graphql/morpho/schema.graphql create mode 100644 api/morphographql/generated.go create mode 100644 api/morphographql/scalars/scalars.go create mode 100644 openapi/redstone-oev-ws.zod.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7c6867f2..9af0ab3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,13 @@ Two layers, and code lives in exactly one: (today `bridgefacilitator/`). All protocol-specific logic, types, ABIs usage, pricing, and config live here. +**Shared protocol code** used by ≥2 solvers lives in its own shared package or generated binding — e.g. +Morpho's math in `internal/morpho/`, generated Morpho GraphQL bindings in `api/morphographql`, or neutral +contract bindings like `api/bindings/liquidlane/adapter` / `api/bindings/erc4626` (shared by redstone-oev + +rfq). Hand-written domain adapters stay inside the solver that owns the workflow unless a second solver +actually reuses them. Neutral, protocol-agnostic helpers (config parsing, etc.) live in their own small +helper package — `internal/parse`. + To add a new integration (e.g. `rfq`): 1. Create `internal/solvers/rfq/` implementing `solver.Solver` (`Name()`, `Run(ctx)`), with a `Factory(raw yaml.Node, deps solver.Deps) (Solver, error)`. @@ -104,7 +111,7 @@ bot's view of an external surface honest (it comes from the source of truth, not that silently drifts), keeps the build hermetic (generated code is committed, so a clean checkout builds with no network/toolchain surprises), and turns an upstream change into a reviewable diff. -Two instances of the same pattern — **vendor → generate → commit, regenerated only via `make`:** +Three instances of the same pattern — **vendor → generate → commit, regenerated only via `make`:** - **Contract bindings (ABI → abigen).** Vendor the ABI JSON under `api/abi/` (from a `forge build` out-dir; `make refresh-abi` extracts `.abi` from the build artifacts of `ABIS`/`CORE_MIRROR_ABIS`), @@ -127,8 +134,14 @@ Two instances of the same pattern — **vendor → generate → commit, regenera (e.g. 7.12.0 for an OpenAPI 3.1 spec with numeric `exclusiveMinimum` / `type:[…,null]` unions, which `oapi-codegen`/kin-openapi and `ogen` reject). The recipe strips the generator's non-package cruft (its `go.mod`/docs/test/etc.), keeping only the Go client so it joins the main module. - -Rules for both: the vendored artifact (ABI/spec) is the **contract of record** — when upstream changes, +- **GraphQL clients (schema SDL + operations → genqlient).** Vendor the upstream schema SDL under + `api/graphql//` (`make refresh-morpho-graphql-schema` pulls Morpho's live schema), keep named + operation documents under `operations/`, then `make refresh-morpho-graphql-client` runs pinned + `genqlient` into `api//` and emits `operations.json` for review/safelisting. The generated package + is the shared binding; hand-written adapters that parse generated response types into domain types live in + the owning integration until reuse proves they belong elsewhere. + +Rules for every generated surface: the vendored artifact (ABI/spec/schema) is the **contract of record** — when upstream changes, re-vendor + regenerate in the same change rather than patching generated Go. The integration code wraps the generated client/binding behind a thin adapter so generated types (nullable pointers, response wrappers) stay contained at the boundary and don't leak into solver logic. Reach for this pattern diff --git a/Makefile b/Makefile index ff12c898..f157e016 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,9 @@ SHELL := bash # Pinned codegen tool versions. ABIGEN_VERSION ?= v1.16.1 GOLANGCI_LINT_VERSION ?= v2.11.4 +GENQLIENT_VERSION ?= v0.8.1 +GQLFETCH_VERSION ?= v0.7.0 +GENQLIENT_X_TOOLS_VERSION ?= v0.38.0 # Java openapi-generator (downloaded on demand by hack/openapi-generator-cli.sh). 7.12.0 is the floor: # it ingests OpenAPI 3.1 (the RFQ backend spec); 5.4.0/7.0.1 fail on it. OPENAPI_GENERATOR_VERSION ?= 7.12.0 @@ -25,6 +28,7 @@ OPENAPI_URL ?= https://bf.dev.gcp.3f.xyz/docs/openapi.json # NOTE: the temp railway deployment is currently behind the repo (pre adapter/protocolSignature # rename); point this at a backend running current code, or regenerate in-repo (see docs/RFQ-PLAN.md). RFQ_OPENAPI_URL ?= https://backend-production-a0ca.up.railway.app/api/v1/openapi.json +MORPHO_GRAPHQL_URL ?= https://api.morpho.org/graphql # Contracts whose ABIs are vendored via refresh-abi. ABIS come from the rfq Foundry build; the # CORE_MIRROR_ABIS (LiquidLane adapter, universal delegator, vault/ERC4626 interfaces) come from the @@ -36,21 +40,29 @@ CORE_MIRROR_ABIS := LiquidLaneAdapter IVaultV2 IERC4626 # Contract:relpath mapping for Go bindings. Each contract gets its own package (the leaf dir) so # shared ABI structs (e.g. the `Offer` tuple in both the adapter and IRequest) don't collide. -# Adapter-specific bindings are grouped per integration (3f/, and later rfq/, oev/); shared -# infra (vaultv2, multicall3) stays top-level so every integration reuses it. -# Leaf-contract bindings use abigen --v2, which emits typed, backend-free PackXxx/UnpackXxx helpers. -# The on-chain read paths build their Multicall3 sub-calls and decode the return blobs through those -# helpers (see the chainreaders), so an ABI change that renames a method or alters a signature breaks -# the build at the call site instead of panicking at runtime — no stringly-typed abi.Pack("method"). +# Integration-specific bindings are grouped per integration (3f/, rfq/, oev/); contracts SHARED by more +# than one integration get a neutral group (e.g. the LiquidLane adapter under liquidlane/, used by both +# rfq and redstone-oev) so no integration owns another's surface; shared infra (vaultv2, multicall3) +# stays top-level. +# +# BINDINGS_V2 uses abigen --v2 (typed PackXxx/UnpackXxx/UnpackXxxEvent), so an ABI change breaks the build +# at the call site, not at runtime. BINDINGS_V2 := BridgeFacilitatorAdapter:3f/adapter IRequest:3f/request \ IVaultController:3f/vaultcontroller IWhitelist:3f/whitelist \ - LiquidLaneAdapter:rfq/adapter Executor:rfq/executor Reactor:rfq/reactor \ - UniversalDelegator:delegator IVaultV2:vaultv2 IERC4626:erc4626 -# Note: api/abi/Multicall3.json is hand-vendored (not a Foundry contract), so Multicall3 is in -# BINDINGS_V1 but not ABIS. It stays on the v1 generator: it's the transport (chain.Multicall binds -# its Aggregate3 caller), where v2's pure pack/unpack helpers buy nothing. aggregate3 is marked `view` -# there so abigen binds it as a Caller. -BINDINGS_V1 := Multicall3:multicall3 + LiquidLaneAdapter:liquidlane/adapter Executor:rfq/executor Reactor:rfq/reactor \ + UniversalDelegator:delegator IVaultV2:vaultv2 IERC4626:erc4626 \ + SymbioticOevSolver:oev/callback RedStoneExecutor:oev/executor Morpho:oev/morpho \ + AdaptiveCurveIrm:oev/irm MorphoOracle:oev/oracle \ + AggregatorV3:oev/aggregator \ + ERC20:erc20 Multicall3:multicall3 +# The OEV contracts (Morpho + its AdaptiveCurve IRM + market oracle, RedStone +# Executor, SymbioticOevSolver) plus a minimal ERC20 (decimals() only) aren't in our Foundry build, so their +# ABIs are hand-vendored under api/abi/ (not in ABIS/CORE_MIRROR_ABIS/refresh-abi). RedStoneExecutor avoids +# the rfq Executor name clash; solver ERC-20 reads (asset/balanceOf) reuse erc4626, the generic +# chain.Decimals reader uses erc20. +# Multicall3 is v2 like everything else — api/abi/Multicall3.json is hand-vendored (not a Foundry contract), +# so it's in BINDINGS_V2 but not ABIS. The chain.Multicall transport packs/unpacks aggregate3 and does its +# own eth_call. BIN := bin/vault-solver PKG := github.com/symbioticfi/vault-solver @@ -71,6 +83,7 @@ tools: ## Install pinned codegen + lint tools go install github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) @echo "OpenAPI clients use the Java openapi-generator via hack/openapi-generator-cli.sh (needs a JRE; jar auto-downloaded)." + @echo "Morpho GraphQL uses gqlfetch + genqlient through go run in the make targets." .PHONY: refresh-abi refresh-abi: ## Re-vendor ABIs from the rfq + core-mirror Foundry builds (FORGE_OUT=..., CORE_MIRROR_OUT=...) @@ -100,6 +113,13 @@ refresh-rfq-openapi: ## Re-pull the RFQ backend OpenAPI spec (RFQ_OPENAPI_URL=.. curl -fsSL "$(RFQ_OPENAPI_URL)" | jq . > openapi/rfq-backend.openapi.json @echo "vendored openapi/rfq-backend.openapi.json (verify field names — see docs/RFQ-PLAN.md)" +.PHONY: refresh-morpho-graphql-schema +refresh-morpho-graphql-schema: ## Re-pull the live Morpho GraphQL schema SDL (MORPHO_GRAPHQL_URL=...) + @mkdir -p api/graphql/morpho + go run github.com/suessflorian/gqlfetch/gqlfetch@$(GQLFETCH_VERSION) \ + -endpoint "$(MORPHO_GRAPHQL_URL)" > api/graphql/morpho/schema.graphql + @echo "vendored api/graphql/morpho/schema.graphql" + .PHONY: bindings bindings: ## Generate Go bindings from vendored ABIs (grouped per integration; package = leaf dir) @for pair in $(BINDINGS_V2); do \ @@ -110,14 +130,6 @@ bindings: ## Generate Go bindings from vendored ABIs (grouped per integration; p abigen --v2 --abi "$$abi" --pkg "$$pkg" --type "$$c" --out "api/bindings/$$rel/$$c.go"; \ echo "generated api/bindings/$$rel/$$c.go (v2)"; \ done - @for pair in $(BINDINGS_V1); do \ - c="$${pair%%:*}"; rel="$${pair##*:}"; pkg="$${rel##*/}"; \ - abi="api/abi/$$c.json"; \ - if [[ ! -f "$$abi" ]]; then echo "missing $$abi (run make refresh-abi)"; exit 1; fi; \ - mkdir -p "api/bindings/$$rel"; \ - abigen --abi "$$abi" --pkg "$$pkg" --type "$$c" --out "api/bindings/$$rel/$$c.go"; \ - echo "generated api/bindings/$$rel/$$c.go (v1)"; \ - done # Both OpenAPI clients are generated with the Java openapi-generator (via hack/openapi-generator-cli.sh, # which downloads the pinned jar on demand — needs a JRE). It is the only generator that ingests the RFQ @@ -140,11 +152,25 @@ refresh-rfq-client: ## Generate the RFQ backend client (openapi-generator, Go) f @rm -f api/rfqbackend/*.go $(call gen_openapi_client,openapi/rfq-backend.openapi.json,api/rfqbackend,rfqbackend) +.PHONY: refresh-morpho-graphql-client +refresh-morpho-graphql-client: ## Generate the Morpho GraphQL client (genqlient) from the vendored schema + operations + @mkdir -p api/morphographql + @tmp="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmp"' EXIT; \ + cd "$$tmp"; \ + go mod init genqlient-runner >/dev/null 2>&1; \ + go get github.com/Khan/genqlient@$(GENQLIENT_VERSION) golang.org/x/tools@$(GENQLIENT_X_TOOLS_VERSION) >/dev/null 2>&1; \ + go run github.com/Khan/genqlient "$(CURDIR)/api/graphql/morpho/genqlient.yaml" + @gofmt -w api/morphographql/generated.go + .PHONY: openapi-client openapi-client: refresh-3f-client refresh-rfq-client ## Generate both OpenAPI clients +.PHONY: graphql-client +graphql-client: refresh-morpho-graphql-client ## Generate GraphQL clients + .PHONY: generate -generate: bindings openapi-client ## Regenerate all committed codegen +generate: bindings openapi-client graphql-client ## Regenerate all committed codegen .PHONY: build build: ## Build the binary @@ -152,9 +178,18 @@ build: ## Build the binary go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/vault-solver .PHONY: test -test: ## Run tests with race detector + coverage +test: ## Run tests with race detector + coverage (hermetic only; fork/live suites are tag-gated out) go test -race -cover ./... +# Local-only OEV integration suite — build-tagged, skipped by the default `test` + CI. +.PHONY: test-oev-live +test-oev-live: ## OEV live checks — Morpho API borrower + token-pair market discovery + go test -tags live -run TestLive -v ./internal/solvers/redstoneoev/ + +.PHONY: test-oev-refuel +test-oev-refuel: ## OEV gas-refuel orchestration on an anvil Sepolia fork (needs ETH_RPC_URL_SEPOLIA + OEV_SIGNER_PRIVATE_KEY + sibling rfq-integration) + ./scripts/oev/oev-fork-refuel.sh + .PHONY: format format: ## Run golangci-lint golangci-lint run --fix diff --git a/api/abi/AdaptiveCurveIrm.json b/api/abi/AdaptiveCurveIrm.json new file mode 100644 index 00000000..59a840f3 --- /dev/null +++ b/api/abi/AdaptiveCurveIrm.json @@ -0,0 +1,70 @@ +[ + { + "inputs": [ + { + "name": "marketParams", + "type": "tuple", + "components": [ + { + "name": "loanToken", + "type": "address" + }, + { + "name": "collateralToken", + "type": "address" + }, + { + "name": "oracle", + "type": "address" + }, + { + "name": "irm", + "type": "address" + }, + { + "name": "lltv", + "type": "uint256" + } + ] + }, + { + "name": "market", + "type": "tuple", + "components": [ + { + "name": "totalSupplyAssets", + "type": "uint128" + }, + { + "name": "totalSupplyShares", + "type": "uint128" + }, + { + "name": "totalBorrowAssets", + "type": "uint128" + }, + { + "name": "totalBorrowShares", + "type": "uint128" + }, + { + "name": "lastUpdate", + "type": "uint128" + }, + { + "name": "fee", + "type": "uint128" + } + ] + } + ], + "name": "borrowRateView", + "outputs": [ + { + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/AggregatorV3.json b/api/abi/AggregatorV3.json new file mode 100644 index 00000000..1176072a --- /dev/null +++ b/api/abi/AggregatorV3.json @@ -0,0 +1,41 @@ +[ + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + { + "name": "roundId", + "type": "uint80" + }, + { + "name": "answer", + "type": "int256" + }, + { + "name": "startedAt", + "type": "uint256" + }, + { + "name": "updatedAt", + "type": "uint256" + }, + { + "name": "answeredInRound", + "type": "uint80" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/ERC20.json b/api/abi/ERC20.json new file mode 100644 index 00000000..ba7d0f58 --- /dev/null +++ b/api/abi/ERC20.json @@ -0,0 +1,9 @@ +[ + { + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/Morpho.json b/api/abi/Morpho.json new file mode 100644 index 00000000..77ed04d5 --- /dev/null +++ b/api/abi/Morpho.json @@ -0,0 +1,97 @@ +[ + { + "inputs": [ + { + "type": "bytes32" + } + ], + "name": "market", + "outputs": [ + { + "name": "totalSupplyAssets", + "type": "uint128" + }, + { + "name": "totalSupplyShares", + "type": "uint128" + }, + { + "name": "totalBorrowAssets", + "type": "uint128" + }, + { + "name": "totalBorrowShares", + "type": "uint128" + }, + { + "name": "lastUpdate", + "type": "uint128" + }, + { + "name": "fee", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "type": "bytes32" + }, + { + "type": "address" + } + ], + "name": "position", + "outputs": [ + { + "name": "supplyShares", + "type": "uint256" + }, + { + "name": "borrowShares", + "type": "uint128" + }, + { + "name": "collateral", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "type": "bytes32" + } + ], + "name": "idToMarketParams", + "outputs": [ + { + "name": "loanToken", + "type": "address" + }, + { + "name": "collateralToken", + "type": "address" + }, + { + "name": "oracle", + "type": "address" + }, + { + "name": "irm", + "type": "address" + }, + { + "name": "lltv", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/MorphoOracle.json b/api/abi/MorphoOracle.json new file mode 100644 index 00000000..59d476d8 --- /dev/null +++ b/api/abi/MorphoOracle.json @@ -0,0 +1,13 @@ +[ + { + "inputs": [], + "name": "price", + "outputs": [ + { + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/RedStoneExecutor.json b/api/abi/RedStoneExecutor.json new file mode 100644 index 00000000..e0cc2376 --- /dev/null +++ b/api/abi/RedStoneExecutor.json @@ -0,0 +1,71 @@ +[ + { + "inputs": [ + { + "type": "address" + } + ], + "name": "nonces", + "outputs": [ + { + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "type": "address" + } + ], + "name": "deposits", + "outputs": [ + { + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "type": "address" + } + ], + "name": "locked", + "outputs": [ + { + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "deposit", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "solver", + "type": "address" + }, + { + "indexed": false, + "name": "nonce", + "type": "uint256" + } + ], + "name": "LiquidationFailed", + "type": "event" + } +] diff --git a/api/abi/SymbioticOevSolver.json b/api/abi/SymbioticOevSolver.json new file mode 100644 index 00000000..ccbb84f9 --- /dev/null +++ b/api/abi/SymbioticOevSolver.json @@ -0,0 +1,400 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "morpho", + "type": "address", + "internalType": "address" + }, + { + "name": "liquidLaneAdapter", + "type": "address", + "internalType": "address" + }, + { + "name": "authSigner", + "type": "address", + "internalType": "address" + }, + { + "name": "initialOwner", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "AUTH_SIGNER", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "EXECUTOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "LIQUID_LANE_ADAPTER", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MORPHO", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "liquidate", + "inputs": [ + { + "name": "bidAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "operationData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "onMorphoLiquidate", + "inputs": [ + { + "name": "repaidAssets", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "payBid", + "inputs": [ + { + "name": "bidAmount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "usedAuctionKey", + "inputs": [ + { + "name": "auctionKey", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "used", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "withdrawERC20", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "withdrawNative", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "LegResult", + "inputs": [ + { + "name": "auctionKey", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "marketId", + "type": "bytes32", + "indexed": true, + "internalType": "Id" + }, + { + "name": "borrower", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "code", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "seizedAssets", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "repaidAssets", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "profitLoan", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnerUpdated", + "inputs": [ + { + "name": "previous", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "next", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PayBidResult", + "inputs": [ + { + "name": "auctionKey", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "bidAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "paid", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "BundleProfitBelowMin", + "inputs": [] + }, + { + "type": "error", + "name": "ECDSAInvalidSignature", + "inputs": [] + }, + { + "type": "error", + "name": "ECDSAInvalidSignatureLength", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ECDSAInvalidSignatureS", + "inputs": [ + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidAuth", + "inputs": [] + }, + { + "type": "error", + "name": "NotExecutor", + "inputs": [] + }, + { + "type": "error", + "name": "NotMorpho", + "inputs": [] + }, + { + "type": "error", + "name": "NotOwner", + "inputs": [] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "TransferFailed", + "inputs": [] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + } +] diff --git a/api/bindings/erc20/ERC20.go b/api/bindings/erc20/ERC20.go new file mode 100644 index 00000000..ff1d66a5 --- /dev/null +++ b/api/bindings/erc20/ERC20.go @@ -0,0 +1,86 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package erc20 + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// ERC20MetaData contains all meta data concerning the ERC20 contract. +var ERC20MetaData = bind.MetaData{ + ABI: "[{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "ERC20", +} + +// ERC20 is an auto generated Go binding around an Ethereum contract. +type ERC20 struct { + abi abi.ABI +} + +// NewERC20 creates a new instance of ERC20. +func NewERC20() *ERC20 { + parsed, err := ERC20MetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &ERC20{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *ERC20) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackDecimals is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x313ce567. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function decimals() view returns(uint8) +func (eRC20 *ERC20) PackDecimals() []byte { + enc, err := eRC20.abi.Pack("decimals") + if err != nil { + panic(err) + } + return enc +} + +// TryPackDecimals is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x313ce567. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function decimals() view returns(uint8) +func (eRC20 *ERC20) TryPackDecimals() ([]byte, error) { + return eRC20.abi.Pack("decimals") +} + +// UnpackDecimals is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (eRC20 *ERC20) UnpackDecimals(data []byte) (uint8, error) { + out, err := eRC20.abi.Unpack("decimals", data) + if err != nil { + return *new(uint8), err + } + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + return out0, nil +} diff --git a/api/bindings/rfq/adapter/LiquidLaneAdapter.go b/api/bindings/liquidlane/adapter/LiquidLaneAdapter.go similarity index 100% rename from api/bindings/rfq/adapter/LiquidLaneAdapter.go rename to api/bindings/liquidlane/adapter/LiquidLaneAdapter.go diff --git a/api/bindings/multicall3/Multicall3.go b/api/bindings/multicall3/Multicall3.go index 387e4e66..57d31faf 100644 --- a/api/bindings/multicall3/Multicall3.go +++ b/api/bindings/multicall3/Multicall3.go @@ -1,31 +1,26 @@ -// Code generated - DO NOT EDIT. +// Code generated via abigen V2 - DO NOT EDIT. // This file is a generated binding and any manual changes will be lost. package multicall3 import ( + "bytes" "errors" "math/big" - "strings" - ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" ) // Reference imports to suppress errors if they are not otherwise used. var ( + _ = bytes.Equal _ = errors.New _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind _ = common.Big1 _ = types.BloomLookup - _ = event.NewSubscription _ = abi.ConvertType ) @@ -43,183 +38,62 @@ type Multicall3Result struct { } // Multicall3MetaData contains all meta data concerning the Multicall3 contract. -var Multicall3MetaData = &bind.MetaData{ +var Multicall3MetaData = bind.MetaData{ ABI: "[{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"target\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"allowFailure\",\"type\":\"bool\"},{\"internalType\":\"bytes\",\"name\":\"callData\",\"type\":\"bytes\"}],\"internalType\":\"structMulticall3.Call3[]\",\"name\":\"calls\",\"type\":\"tuple[]\"}],\"name\":\"aggregate3\",\"outputs\":[{\"components\":[{\"internalType\":\"bool\",\"name\":\"success\",\"type\":\"bool\"},{\"internalType\":\"bytes\",\"name\":\"returnData\",\"type\":\"bytes\"}],\"internalType\":\"structMulticall3.Result[]\",\"name\":\"returnData\",\"type\":\"tuple[]\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "Multicall3", } -// Multicall3ABI is the input ABI used to generate the binding from. -// Deprecated: Use Multicall3MetaData.ABI instead. -var Multicall3ABI = Multicall3MetaData.ABI - // Multicall3 is an auto generated Go binding around an Ethereum contract. type Multicall3 struct { - Multicall3Caller // Read-only binding to the contract - Multicall3Transactor // Write-only binding to the contract - Multicall3Filterer // Log filterer for contract events -} - -// Multicall3Caller is an auto generated read-only Go binding around an Ethereum contract. -type Multicall3Caller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// Multicall3Transactor is an auto generated write-only Go binding around an Ethereum contract. -type Multicall3Transactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// Multicall3Filterer is an auto generated log filtering Go binding around an Ethereum contract events. -type Multicall3Filterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// Multicall3Session is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type Multicall3Session struct { - Contract *Multicall3 // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// Multicall3CallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type Multicall3CallerSession struct { - Contract *Multicall3Caller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// Multicall3TransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type Multicall3TransactorSession struct { - Contract *Multicall3Transactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// Multicall3Raw is an auto generated low-level Go binding around an Ethereum contract. -type Multicall3Raw struct { - Contract *Multicall3 // Generic contract binding to access the raw methods on -} - -// Multicall3CallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type Multicall3CallerRaw struct { - Contract *Multicall3Caller // Generic read-only contract binding to access the raw methods on -} - -// Multicall3TransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type Multicall3TransactorRaw struct { - Contract *Multicall3Transactor // Generic write-only contract binding to access the raw methods on -} - -// NewMulticall3 creates a new instance of Multicall3, bound to a specific deployed contract. -func NewMulticall3(address common.Address, backend bind.ContractBackend) (*Multicall3, error) { - contract, err := bindMulticall3(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &Multicall3{Multicall3Caller: Multicall3Caller{contract: contract}, Multicall3Transactor: Multicall3Transactor{contract: contract}, Multicall3Filterer: Multicall3Filterer{contract: contract}}, nil + abi abi.ABI } -// NewMulticall3Caller creates a new read-only instance of Multicall3, bound to a specific deployed contract. -func NewMulticall3Caller(address common.Address, caller bind.ContractCaller) (*Multicall3Caller, error) { - contract, err := bindMulticall3(address, caller, nil, nil) +// NewMulticall3 creates a new instance of Multicall3. +func NewMulticall3() *Multicall3 { + parsed, err := Multicall3MetaData.ParseABI() if err != nil { - return nil, err + panic(errors.New("invalid ABI: " + err.Error())) } - return &Multicall3Caller{contract: contract}, nil + return &Multicall3{abi: *parsed} } -// NewMulticall3Transactor creates a new write-only instance of Multicall3, bound to a specific deployed contract. -func NewMulticall3Transactor(address common.Address, transactor bind.ContractTransactor) (*Multicall3Transactor, error) { - contract, err := bindMulticall3(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &Multicall3Transactor{contract: contract}, nil +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *Multicall3) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) } -// NewMulticall3Filterer creates a new log filterer instance of Multicall3, bound to a specific deployed contract. -func NewMulticall3Filterer(address common.Address, filterer bind.ContractFilterer) (*Multicall3Filterer, error) { - contract, err := bindMulticall3(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &Multicall3Filterer{contract: contract}, nil -} - -// bindMulticall3 binds a generic wrapper to an already deployed contract. -func bindMulticall3(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := Multicall3MetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_Multicall3 *Multicall3Raw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _Multicall3.Contract.Multicall3Caller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_Multicall3 *Multicall3Raw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _Multicall3.Contract.Multicall3Transactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_Multicall3 *Multicall3Raw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _Multicall3.Contract.Multicall3Transactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_Multicall3 *Multicall3CallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _Multicall3.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_Multicall3 *Multicall3TransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _Multicall3.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_Multicall3 *Multicall3TransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _Multicall3.Contract.contract.Transact(opts, method, params...) -} - -// Aggregate3 is a free data retrieval call binding the contract method 0x82ad56cb. +// PackAggregate3 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x82ad56cb. This method will panic if any +// invalid/nil inputs are passed. // // Solidity: function aggregate3((address,bool,bytes)[] calls) view returns((bool,bytes)[] returnData) -func (_Multicall3 *Multicall3Caller) Aggregate3(opts *bind.CallOpts, calls []Multicall3Call3) ([]Multicall3Result, error) { - var out []interface{} - err := _Multicall3.contract.Call(opts, &out, "aggregate3", calls) - +func (multicall3 *Multicall3) PackAggregate3(calls []Multicall3Call3) []byte { + enc, err := multicall3.abi.Pack("aggregate3", calls) if err != nil { - return *new([]Multicall3Result), err + panic(err) } - - out0 := *abi.ConvertType(out[0], new([]Multicall3Result)).(*[]Multicall3Result) - - return out0, err - + return enc } -// Aggregate3 is a free data retrieval call binding the contract method 0x82ad56cb. +// TryPackAggregate3 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x82ad56cb. This method will return an error +// if any inputs are invalid/nil. // // Solidity: function aggregate3((address,bool,bytes)[] calls) view returns((bool,bytes)[] returnData) -func (_Multicall3 *Multicall3Session) Aggregate3(calls []Multicall3Call3) ([]Multicall3Result, error) { - return _Multicall3.Contract.Aggregate3(&_Multicall3.CallOpts, calls) +func (multicall3 *Multicall3) TryPackAggregate3(calls []Multicall3Call3) ([]byte, error) { + return multicall3.abi.Pack("aggregate3", calls) } -// Aggregate3 is a free data retrieval call binding the contract method 0x82ad56cb. +// UnpackAggregate3 is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x82ad56cb. // // Solidity: function aggregate3((address,bool,bytes)[] calls) view returns((bool,bytes)[] returnData) -func (_Multicall3 *Multicall3CallerSession) Aggregate3(calls []Multicall3Call3) ([]Multicall3Result, error) { - return _Multicall3.Contract.Aggregate3(&_Multicall3.CallOpts, calls) +func (multicall3 *Multicall3) UnpackAggregate3(data []byte) ([]Multicall3Result, error) { + out, err := multicall3.abi.Unpack("aggregate3", data) + if err != nil { + return *new([]Multicall3Result), err + } + out0 := *abi.ConvertType(out[0], new([]Multicall3Result)).(*[]Multicall3Result) + return out0, nil } diff --git a/api/bindings/oev/aggregator/AggregatorV3.go b/api/bindings/oev/aggregator/AggregatorV3.go new file mode 100644 index 00000000..0e5dc6b3 --- /dev/null +++ b/api/bindings/oev/aggregator/AggregatorV3.go @@ -0,0 +1,136 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package aggregator + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// AggregatorV3MetaData contains all meta data concerning the AggregatorV3 contract. +var AggregatorV3MetaData = bind.MetaData{ + ABI: "[{\"inputs\":[],\"name\":\"latestRoundData\",\"outputs\":[{\"name\":\"roundId\",\"type\":\"uint80\"},{\"name\":\"answer\",\"type\":\"int256\"},{\"name\":\"startedAt\",\"type\":\"uint256\"},{\"name\":\"updatedAt\",\"type\":\"uint256\"},{\"name\":\"answeredInRound\",\"type\":\"uint80\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "AggregatorV3", +} + +// AggregatorV3 is an auto generated Go binding around an Ethereum contract. +type AggregatorV3 struct { + abi abi.ABI +} + +// NewAggregatorV3 creates a new instance of AggregatorV3. +func NewAggregatorV3() *AggregatorV3 { + parsed, err := AggregatorV3MetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &AggregatorV3{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *AggregatorV3) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackDecimals is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x313ce567. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function decimals() view returns(uint8) +func (aggregatorV3 *AggregatorV3) PackDecimals() []byte { + enc, err := aggregatorV3.abi.Pack("decimals") + if err != nil { + panic(err) + } + return enc +} + +// TryPackDecimals is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x313ce567. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function decimals() view returns(uint8) +func (aggregatorV3 *AggregatorV3) TryPackDecimals() ([]byte, error) { + return aggregatorV3.abi.Pack("decimals") +} + +// UnpackDecimals is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (aggregatorV3 *AggregatorV3) UnpackDecimals(data []byte) (uint8, error) { + out, err := aggregatorV3.abi.Unpack("decimals", data) + if err != nil { + return *new(uint8), err + } + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + return out0, nil +} + +// PackLatestRoundData is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfeaf968c. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function latestRoundData() view returns(uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) +func (aggregatorV3 *AggregatorV3) PackLatestRoundData() []byte { + enc, err := aggregatorV3.abi.Pack("latestRoundData") + if err != nil { + panic(err) + } + return enc +} + +// TryPackLatestRoundData is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfeaf968c. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function latestRoundData() view returns(uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) +func (aggregatorV3 *AggregatorV3) TryPackLatestRoundData() ([]byte, error) { + return aggregatorV3.abi.Pack("latestRoundData") +} + +// LatestRoundDataOutput serves as a container for the return parameters of contract +// method LatestRoundData. +type LatestRoundDataOutput struct { + RoundId *big.Int + Answer *big.Int + StartedAt *big.Int + UpdatedAt *big.Int + AnsweredInRound *big.Int +} + +// UnpackLatestRoundData is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xfeaf968c. +// +// Solidity: function latestRoundData() view returns(uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) +func (aggregatorV3 *AggregatorV3) UnpackLatestRoundData(data []byte) (LatestRoundDataOutput, error) { + out, err := aggregatorV3.abi.Unpack("latestRoundData", data) + outstruct := new(LatestRoundDataOutput) + if err != nil { + return *outstruct, err + } + outstruct.RoundId = abi.ConvertType(out[0], new(big.Int)).(*big.Int) + outstruct.Answer = abi.ConvertType(out[1], new(big.Int)).(*big.Int) + outstruct.StartedAt = abi.ConvertType(out[2], new(big.Int)).(*big.Int) + outstruct.UpdatedAt = abi.ConvertType(out[3], new(big.Int)).(*big.Int) + outstruct.AnsweredInRound = abi.ConvertType(out[4], new(big.Int)).(*big.Int) + return *outstruct, nil +} diff --git a/api/bindings/oev/callback/SymbioticOevSolver.go b/api/bindings/oev/callback/SymbioticOevSolver.go new file mode 100644 index 00000000..94618770 --- /dev/null +++ b/api/bindings/oev/callback/SymbioticOevSolver.go @@ -0,0 +1,858 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package callback + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// SymbioticOevSolverMetaData contains all meta data concerning the SymbioticOevSolver contract. +var SymbioticOevSolverMetaData = bind.MetaData{ + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"executor\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"morpho\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"liquidLaneAdapter\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"authSigner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"initialOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"receive\",\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"AUTH_SIGNER\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"EXECUTOR\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"LIQUID_LANE_ADAPTER\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"MORPHO\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"liquidate\",\"inputs\":[{\"name\":\"bidAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operationData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"onMorphoLiquidate\",\"inputs\":[{\"name\":\"repaidAssets\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"payBid\",\"inputs\":[{\"name\":\"bidAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"usedAuctionKey\",\"inputs\":[{\"name\":\"auctionKey\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"used\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdrawERC20\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawNative\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"LegResult\",\"inputs\":[{\"name\":\"auctionKey\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"marketId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Id\"},{\"name\":\"borrower\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"code\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"seizedAssets\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"repaidAssets\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"profitLoan\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnerUpdated\",\"inputs\":[{\"name\":\"previous\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"next\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PayBidResult\",\"inputs\":[{\"name\":\"auctionKey\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"bidAmount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"paid\",\"type\":\"bool\",\"indexed\":false,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"BundleProfitBelowMin\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignatureLength\",\"inputs\":[{\"name\":\"length\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignatureS\",\"inputs\":[{\"name\":\"s\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidAuth\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotExecutor\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotMorpho\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotOwner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyGuardReentrantCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"TransferFailed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ZeroAddress\",\"inputs\":[]}]", + ID: "SymbioticOevSolver", +} + +// SymbioticOevSolver is an auto generated Go binding around an Ethereum contract. +type SymbioticOevSolver struct { + abi abi.ABI +} + +// NewSymbioticOevSolver creates a new instance of SymbioticOevSolver. +func NewSymbioticOevSolver() *SymbioticOevSolver { + parsed, err := SymbioticOevSolverMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &SymbioticOevSolver{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *SymbioticOevSolver) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackConstructor is the Go binding used to pack the parameters required for +// contract deployment. +// +// Solidity: constructor(address executor, address morpho, address liquidLaneAdapter, address authSigner, address initialOwner) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackConstructor(executor common.Address, morpho common.Address, liquidLaneAdapter common.Address, authSigner common.Address, initialOwner common.Address) []byte { + enc, err := symbioticOevSolver.abi.Pack("", executor, morpho, liquidLaneAdapter, authSigner, initialOwner) + if err != nil { + panic(err) + } + return enc +} + +// PackAUTHSIGNER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0a5c9024. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function AUTH_SIGNER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackAUTHSIGNER() []byte { + enc, err := symbioticOevSolver.abi.Pack("AUTH_SIGNER") + if err != nil { + panic(err) + } + return enc +} + +// TryPackAUTHSIGNER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0a5c9024. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function AUTH_SIGNER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackAUTHSIGNER() ([]byte, error) { + return symbioticOevSolver.abi.Pack("AUTH_SIGNER") +} + +// UnpackAUTHSIGNER is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x0a5c9024. +// +// Solidity: function AUTH_SIGNER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackAUTHSIGNER(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("AUTH_SIGNER", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackEXECUTOR is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x630dc7cb. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function EXECUTOR() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackEXECUTOR() []byte { + enc, err := symbioticOevSolver.abi.Pack("EXECUTOR") + if err != nil { + panic(err) + } + return enc +} + +// TryPackEXECUTOR is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x630dc7cb. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function EXECUTOR() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackEXECUTOR() ([]byte, error) { + return symbioticOevSolver.abi.Pack("EXECUTOR") +} + +// UnpackEXECUTOR is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x630dc7cb. +// +// Solidity: function EXECUTOR() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackEXECUTOR(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("EXECUTOR", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackLIQUIDLANEADAPTER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x86e7c9d0. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function LIQUID_LANE_ADAPTER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackLIQUIDLANEADAPTER() []byte { + enc, err := symbioticOevSolver.abi.Pack("LIQUID_LANE_ADAPTER") + if err != nil { + panic(err) + } + return enc +} + +// TryPackLIQUIDLANEADAPTER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x86e7c9d0. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function LIQUID_LANE_ADAPTER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackLIQUIDLANEADAPTER() ([]byte, error) { + return symbioticOevSolver.abi.Pack("LIQUID_LANE_ADAPTER") +} + +// UnpackLIQUIDLANEADAPTER is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x86e7c9d0. +// +// Solidity: function LIQUID_LANE_ADAPTER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackLIQUIDLANEADAPTER(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("LIQUID_LANE_ADAPTER", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackMORPHO is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x3acb5624. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function MORPHO() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackMORPHO() []byte { + enc, err := symbioticOevSolver.abi.Pack("MORPHO") + if err != nil { + panic(err) + } + return enc +} + +// TryPackMORPHO is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x3acb5624. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function MORPHO() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackMORPHO() ([]byte, error) { + return symbioticOevSolver.abi.Pack("MORPHO") +} + +// UnpackMORPHO is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x3acb5624. +// +// Solidity: function MORPHO() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackMORPHO(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("MORPHO", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackLiquidate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ebcdf30. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function liquidate(uint256 bidAmount, address , bytes operationData) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackLiquidate(bidAmount *big.Int, arg1 common.Address, operationData []byte) []byte { + enc, err := symbioticOevSolver.abi.Pack("liquidate", bidAmount, arg1, operationData) + if err != nil { + panic(err) + } + return enc +} + +// TryPackLiquidate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ebcdf30. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function liquidate(uint256 bidAmount, address , bytes operationData) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackLiquidate(bidAmount *big.Int, arg1 common.Address, operationData []byte) ([]byte, error) { + return symbioticOevSolver.abi.Pack("liquidate", bidAmount, arg1, operationData) +} + +// PackOnMorphoLiquidate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcf7ea196. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function onMorphoLiquidate(uint256 repaidAssets, bytes data) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackOnMorphoLiquidate(repaidAssets *big.Int, data []byte) []byte { + enc, err := symbioticOevSolver.abi.Pack("onMorphoLiquidate", repaidAssets, data) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOnMorphoLiquidate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcf7ea196. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function onMorphoLiquidate(uint256 repaidAssets, bytes data) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackOnMorphoLiquidate(repaidAssets *big.Int, data []byte) ([]byte, error) { + return symbioticOevSolver.abi.Pack("onMorphoLiquidate", repaidAssets, data) +} + +// PackOwner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8da5cb5b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function owner() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackOwner() []byte { + enc, err := symbioticOevSolver.abi.Pack("owner") + if err != nil { + panic(err) + } + return enc +} + +// TryPackOwner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8da5cb5b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function owner() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackOwner() ([]byte, error) { + return symbioticOevSolver.abi.Pack("owner") +} + +// UnpackOwner is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackOwner(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("owner", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackPayBid is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1e1769ed. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function payBid(uint256 bidAmount) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackPayBid(bidAmount *big.Int) []byte { + enc, err := symbioticOevSolver.abi.Pack("payBid", bidAmount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackPayBid is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1e1769ed. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function payBid(uint256 bidAmount) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackPayBid(bidAmount *big.Int) ([]byte, error) { + return symbioticOevSolver.abi.Pack("payBid", bidAmount) +} + +// PackTransferOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf2fde38b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackTransferOwnership(newOwner common.Address) []byte { + enc, err := symbioticOevSolver.abi.Pack("transferOwnership", newOwner) + if err != nil { + panic(err) + } + return enc +} + +// TryPackTransferOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf2fde38b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackTransferOwnership(newOwner common.Address) ([]byte, error) { + return symbioticOevSolver.abi.Pack("transferOwnership", newOwner) +} + +// PackUsedAuctionKey is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0f9e1b51. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function usedAuctionKey(bytes32 auctionKey) view returns(bool used) +func (symbioticOevSolver *SymbioticOevSolver) PackUsedAuctionKey(auctionKey [32]byte) []byte { + enc, err := symbioticOevSolver.abi.Pack("usedAuctionKey", auctionKey) + if err != nil { + panic(err) + } + return enc +} + +// TryPackUsedAuctionKey is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0f9e1b51. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function usedAuctionKey(bytes32 auctionKey) view returns(bool used) +func (symbioticOevSolver *SymbioticOevSolver) TryPackUsedAuctionKey(auctionKey [32]byte) ([]byte, error) { + return symbioticOevSolver.abi.Pack("usedAuctionKey", auctionKey) +} + +// UnpackUsedAuctionKey is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x0f9e1b51. +// +// Solidity: function usedAuctionKey(bytes32 auctionKey) view returns(bool used) +func (symbioticOevSolver *SymbioticOevSolver) UnpackUsedAuctionKey(data []byte) (bool, error) { + out, err := symbioticOevSolver.abi.Unpack("usedAuctionKey", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + +// PackWithdrawERC20 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x44004cc1. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function withdrawERC20(address token, address to, uint256 amount) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackWithdrawERC20(token common.Address, to common.Address, amount *big.Int) []byte { + enc, err := symbioticOevSolver.abi.Pack("withdrawERC20", token, to, amount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackWithdrawERC20 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x44004cc1. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function withdrawERC20(address token, address to, uint256 amount) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackWithdrawERC20(token common.Address, to common.Address, amount *big.Int) ([]byte, error) { + return symbioticOevSolver.abi.Pack("withdrawERC20", token, to, amount) +} + +// PackWithdrawNative is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x07b18bde. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function withdrawNative(address to, uint256 amount) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackWithdrawNative(to common.Address, amount *big.Int) []byte { + enc, err := symbioticOevSolver.abi.Pack("withdrawNative", to, amount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackWithdrawNative is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x07b18bde. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function withdrawNative(address to, uint256 amount) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackWithdrawNative(to common.Address, amount *big.Int) ([]byte, error) { + return symbioticOevSolver.abi.Pack("withdrawNative", to, amount) +} + +// SymbioticOevSolverLegResult represents a LegResult event raised by the SymbioticOevSolver contract. +type SymbioticOevSolverLegResult struct { + AuctionKey [32]byte + MarketId [32]byte + Borrower common.Address + Code *big.Int + SeizedAssets *big.Int + RepaidAssets *big.Int + ProfitLoan *big.Int + Raw *types.Log // Blockchain specific contextual infos +} + +const SymbioticOevSolverLegResultEventName = "LegResult" + +// ContractEventName returns the user-defined event name. +func (SymbioticOevSolverLegResult) ContractEventName() string { + return SymbioticOevSolverLegResultEventName +} + +// UnpackLegResultEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event LegResult(bytes32 indexed auctionKey, bytes32 indexed marketId, address indexed borrower, uint256 code, uint256 seizedAssets, uint256 repaidAssets, uint256 profitLoan) +func (symbioticOevSolver *SymbioticOevSolver) UnpackLegResultEvent(log *types.Log) (*SymbioticOevSolverLegResult, error) { + event := "LegResult" + if log.Topics[0] != symbioticOevSolver.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(SymbioticOevSolverLegResult) + if len(log.Data) > 0 { + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range symbioticOevSolver.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// SymbioticOevSolverOwnerUpdated represents a OwnerUpdated event raised by the SymbioticOevSolver contract. +type SymbioticOevSolverOwnerUpdated struct { + Previous common.Address + Next common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const SymbioticOevSolverOwnerUpdatedEventName = "OwnerUpdated" + +// ContractEventName returns the user-defined event name. +func (SymbioticOevSolverOwnerUpdated) ContractEventName() string { + return SymbioticOevSolverOwnerUpdatedEventName +} + +// UnpackOwnerUpdatedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnerUpdated(address indexed previous, address indexed next) +func (symbioticOevSolver *SymbioticOevSolver) UnpackOwnerUpdatedEvent(log *types.Log) (*SymbioticOevSolverOwnerUpdated, error) { + event := "OwnerUpdated" + if log.Topics[0] != symbioticOevSolver.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(SymbioticOevSolverOwnerUpdated) + if len(log.Data) > 0 { + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range symbioticOevSolver.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// SymbioticOevSolverPayBidResult represents a PayBidResult event raised by the SymbioticOevSolver contract. +type SymbioticOevSolverPayBidResult struct { + AuctionKey [32]byte + BidAmount *big.Int + Paid bool + Raw *types.Log // Blockchain specific contextual infos +} + +const SymbioticOevSolverPayBidResultEventName = "PayBidResult" + +// ContractEventName returns the user-defined event name. +func (SymbioticOevSolverPayBidResult) ContractEventName() string { + return SymbioticOevSolverPayBidResultEventName +} + +// UnpackPayBidResultEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event PayBidResult(bytes32 indexed auctionKey, uint256 bidAmount, bool paid) +func (symbioticOevSolver *SymbioticOevSolver) UnpackPayBidResultEvent(log *types.Log) (*SymbioticOevSolverPayBidResult, error) { + event := "PayBidResult" + if log.Topics[0] != symbioticOevSolver.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(SymbioticOevSolverPayBidResult) + if len(log.Data) > 0 { + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range symbioticOevSolver.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// UnpackError attempts to decode the provided error data using user-defined +// error definitions. +func (symbioticOevSolver *SymbioticOevSolver) UnpackError(raw []byte) (any, error) { + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["BundleProfitBelowMin"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackBundleProfitBelowMinError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ECDSAInvalidSignature"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackECDSAInvalidSignatureError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ECDSAInvalidSignatureLength"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackECDSAInvalidSignatureLengthError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ECDSAInvalidSignatureS"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackECDSAInvalidSignatureSError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["InvalidAuth"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackInvalidAuthError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["NotExecutor"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackNotExecutorError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["NotMorpho"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackNotMorphoError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["NotOwner"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackNotOwnerError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ReentrancyGuardReentrantCall"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackReentrancyGuardReentrantCallError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["SafeERC20FailedOperation"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackSafeERC20FailedOperationError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["TransferFailed"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackTransferFailedError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ZeroAddress"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackZeroAddressError(raw[4:]) + } + return nil, errors.New("Unknown error") +} + +// SymbioticOevSolverBundleProfitBelowMin represents a BundleProfitBelowMin error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverBundleProfitBelowMin struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error BundleProfitBelowMin() +func SymbioticOevSolverBundleProfitBelowMinErrorID() common.Hash { + return common.HexToHash("0x29d167cc2d9f1759ad30a5d9e9f77039a4c538c3748c82210d061d4701b53d28") +} + +// UnpackBundleProfitBelowMinError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error BundleProfitBelowMin() +func (symbioticOevSolver *SymbioticOevSolver) UnpackBundleProfitBelowMinError(raw []byte) (*SymbioticOevSolverBundleProfitBelowMin, error) { + out := new(SymbioticOevSolverBundleProfitBelowMin) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "BundleProfitBelowMin", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverECDSAInvalidSignature represents a ECDSAInvalidSignature error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverECDSAInvalidSignature struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ECDSAInvalidSignature() +func SymbioticOevSolverECDSAInvalidSignatureErrorID() common.Hash { + return common.HexToHash("0xf645eedf0193584640b6b90cb9477e4c95b98636c148a891d4c0a146dc46e75a") +} + +// UnpackECDSAInvalidSignatureError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ECDSAInvalidSignature() +func (symbioticOevSolver *SymbioticOevSolver) UnpackECDSAInvalidSignatureError(raw []byte) (*SymbioticOevSolverECDSAInvalidSignature, error) { + out := new(SymbioticOevSolverECDSAInvalidSignature) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ECDSAInvalidSignature", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverECDSAInvalidSignatureLength represents a ECDSAInvalidSignatureLength error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverECDSAInvalidSignatureLength struct { + Length *big.Int +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ECDSAInvalidSignatureLength(uint256 length) +func SymbioticOevSolverECDSAInvalidSignatureLengthErrorID() common.Hash { + return common.HexToHash("0xfce698f7e8e5342cd615f641317bc45fe7e1e4a8b0a14dd1383ff8dc9c41917f") +} + +// UnpackECDSAInvalidSignatureLengthError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ECDSAInvalidSignatureLength(uint256 length) +func (symbioticOevSolver *SymbioticOevSolver) UnpackECDSAInvalidSignatureLengthError(raw []byte) (*SymbioticOevSolverECDSAInvalidSignatureLength, error) { + out := new(SymbioticOevSolverECDSAInvalidSignatureLength) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ECDSAInvalidSignatureLength", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverECDSAInvalidSignatureS represents a ECDSAInvalidSignatureS error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverECDSAInvalidSignatureS struct { + S [32]byte +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ECDSAInvalidSignatureS(bytes32 s) +func SymbioticOevSolverECDSAInvalidSignatureSErrorID() common.Hash { + return common.HexToHash("0xd78bce0cccb935155ed6428d1c13e50b7f3550fd2b66b9fe266006fea4a5e1eb") +} + +// UnpackECDSAInvalidSignatureSError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ECDSAInvalidSignatureS(bytes32 s) +func (symbioticOevSolver *SymbioticOevSolver) UnpackECDSAInvalidSignatureSError(raw []byte) (*SymbioticOevSolverECDSAInvalidSignatureS, error) { + out := new(SymbioticOevSolverECDSAInvalidSignatureS) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ECDSAInvalidSignatureS", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverInvalidAuth represents a InvalidAuth error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverInvalidAuth struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidAuth() +func SymbioticOevSolverInvalidAuthErrorID() common.Hash { + return common.HexToHash("0x60907fd1eaf0aeb8678cf1ed7e0848c38b81ff6b751719093cce13e43c4aa3a7") +} + +// UnpackInvalidAuthError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidAuth() +func (symbioticOevSolver *SymbioticOevSolver) UnpackInvalidAuthError(raw []byte) (*SymbioticOevSolverInvalidAuth, error) { + out := new(SymbioticOevSolverInvalidAuth) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "InvalidAuth", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverNotExecutor represents a NotExecutor error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverNotExecutor struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotExecutor() +func SymbioticOevSolverNotExecutorErrorID() common.Hash { + return common.HexToHash("0xc32d1d764229d81292df6f25b9d1e0888374ee366ac172b5c5162f2d6fcf3ce2") +} + +// UnpackNotExecutorError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotExecutor() +func (symbioticOevSolver *SymbioticOevSolver) UnpackNotExecutorError(raw []byte) (*SymbioticOevSolverNotExecutor, error) { + out := new(SymbioticOevSolverNotExecutor) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "NotExecutor", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverNotMorpho represents a NotMorpho error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverNotMorpho struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotMorpho() +func SymbioticOevSolverNotMorphoErrorID() common.Hash { + return common.HexToHash("0xe51b512366538cee8c853e063e54221c196d4d7f44b7cc806f3763062d129db9") +} + +// UnpackNotMorphoError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotMorpho() +func (symbioticOevSolver *SymbioticOevSolver) UnpackNotMorphoError(raw []byte) (*SymbioticOevSolverNotMorpho, error) { + out := new(SymbioticOevSolverNotMorpho) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "NotMorpho", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverNotOwner represents a NotOwner error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverNotOwner struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotOwner() +func SymbioticOevSolverNotOwnerErrorID() common.Hash { + return common.HexToHash("0x30cd74712f59d478562d48e2d35de830db72c60a63dd08ae59199eec990b5bc4") +} + +// UnpackNotOwnerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotOwner() +func (symbioticOevSolver *SymbioticOevSolver) UnpackNotOwnerError(raw []byte) (*SymbioticOevSolverNotOwner, error) { + out := new(SymbioticOevSolverNotOwner) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "NotOwner", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverReentrancyGuardReentrantCall represents a ReentrancyGuardReentrantCall error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverReentrancyGuardReentrantCall struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ReentrancyGuardReentrantCall() +func SymbioticOevSolverReentrancyGuardReentrantCallErrorID() common.Hash { + return common.HexToHash("0x3ee5aeb571de7fc460830b4d0017439a1ca56fb0bc39062227ade4fe4a24c1ca") +} + +// UnpackReentrancyGuardReentrantCallError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ReentrancyGuardReentrantCall() +func (symbioticOevSolver *SymbioticOevSolver) UnpackReentrancyGuardReentrantCallError(raw []byte) (*SymbioticOevSolverReentrancyGuardReentrantCall, error) { + out := new(SymbioticOevSolverReentrancyGuardReentrantCall) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ReentrancyGuardReentrantCall", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverSafeERC20FailedOperation represents a SafeERC20FailedOperation error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverSafeERC20FailedOperation struct { + Token common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SafeERC20FailedOperation(address token) +func SymbioticOevSolverSafeERC20FailedOperationErrorID() common.Hash { + return common.HexToHash("0x5274afe73c98b4749fc91ffae6b7b574e7842cb2144a159e9377a5f20b32edf9") +} + +// UnpackSafeERC20FailedOperationError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error SafeERC20FailedOperation(address token) +func (symbioticOevSolver *SymbioticOevSolver) UnpackSafeERC20FailedOperationError(raw []byte) (*SymbioticOevSolverSafeERC20FailedOperation, error) { + out := new(SymbioticOevSolverSafeERC20FailedOperation) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "SafeERC20FailedOperation", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverTransferFailed represents a TransferFailed error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverTransferFailed struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error TransferFailed() +func SymbioticOevSolverTransferFailedErrorID() common.Hash { + return common.HexToHash("0x90b8ec1877afffd816d05d9b13947f3ff18ec5851c38bad15ec2b710f92391b1") +} + +// UnpackTransferFailedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error TransferFailed() +func (symbioticOevSolver *SymbioticOevSolver) UnpackTransferFailedError(raw []byte) (*SymbioticOevSolverTransferFailed, error) { + out := new(SymbioticOevSolverTransferFailed) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "TransferFailed", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverZeroAddress represents a ZeroAddress error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverZeroAddress struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ZeroAddress() +func SymbioticOevSolverZeroAddressErrorID() common.Hash { + return common.HexToHash("0xd92e233df2717d4a40030e20904abd27b68fcbeede117eaaccbbdac9618c8c73") +} + +// UnpackZeroAddressError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ZeroAddress() +func (symbioticOevSolver *SymbioticOevSolver) UnpackZeroAddressError(raw []byte) (*SymbioticOevSolverZeroAddress, error) { + out := new(SymbioticOevSolverZeroAddress) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ZeroAddress", raw); err != nil { + return nil, err + } + return out, nil +} diff --git a/api/bindings/oev/executor/RedStoneExecutor.go b/api/bindings/oev/executor/RedStoneExecutor.go new file mode 100644 index 00000000..b3137467 --- /dev/null +++ b/api/bindings/oev/executor/RedStoneExecutor.go @@ -0,0 +1,220 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package executor + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// RedStoneExecutorMetaData contains all meta data concerning the RedStoneExecutor contract. +var RedStoneExecutorMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[{\"type\":\"address\"}],\"name\":\"nonces\",\"outputs\":[{\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"type\":\"address\"}],\"name\":\"deposits\",\"outputs\":[{\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"type\":\"address\"}],\"name\":\"locked\",\"outputs\":[{\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"deposit\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"solver\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"nonce\",\"type\":\"uint256\"}],\"name\":\"LiquidationFailed\",\"type\":\"event\"}]", + ID: "RedStoneExecutor", +} + +// RedStoneExecutor is an auto generated Go binding around an Ethereum contract. +type RedStoneExecutor struct { + abi abi.ABI +} + +// NewRedStoneExecutor creates a new instance of RedStoneExecutor. +func NewRedStoneExecutor() *RedStoneExecutor { + parsed, err := RedStoneExecutorMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &RedStoneExecutor{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *RedStoneExecutor) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackDeposit is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xd0e30db0. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function deposit() payable returns() +func (redStoneExecutor *RedStoneExecutor) PackDeposit() []byte { + enc, err := redStoneExecutor.abi.Pack("deposit") + if err != nil { + panic(err) + } + return enc +} + +// TryPackDeposit is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xd0e30db0. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function deposit() payable returns() +func (redStoneExecutor *RedStoneExecutor) TryPackDeposit() ([]byte, error) { + return redStoneExecutor.abi.Pack("deposit") +} + +// PackDeposits is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfc7e286d. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function deposits(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) PackDeposits(arg0 common.Address) []byte { + enc, err := redStoneExecutor.abi.Pack("deposits", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackDeposits is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfc7e286d. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function deposits(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) TryPackDeposits(arg0 common.Address) ([]byte, error) { + return redStoneExecutor.abi.Pack("deposits", arg0) +} + +// UnpackDeposits is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xfc7e286d. +// +// Solidity: function deposits(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) UnpackDeposits(data []byte) (*big.Int, error) { + out, err := redStoneExecutor.abi.Unpack("deposits", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackLocked is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcbf9fe5f. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function locked(address ) view returns(bool) +func (redStoneExecutor *RedStoneExecutor) PackLocked(arg0 common.Address) []byte { + enc, err := redStoneExecutor.abi.Pack("locked", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackLocked is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcbf9fe5f. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function locked(address ) view returns(bool) +func (redStoneExecutor *RedStoneExecutor) TryPackLocked(arg0 common.Address) ([]byte, error) { + return redStoneExecutor.abi.Pack("locked", arg0) +} + +// UnpackLocked is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xcbf9fe5f. +// +// Solidity: function locked(address ) view returns(bool) +func (redStoneExecutor *RedStoneExecutor) UnpackLocked(data []byte) (bool, error) { + out, err := redStoneExecutor.abi.Unpack("locked", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + +// PackNonces is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ecebe00. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function nonces(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) PackNonces(arg0 common.Address) []byte { + enc, err := redStoneExecutor.abi.Pack("nonces", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackNonces is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ecebe00. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function nonces(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) TryPackNonces(arg0 common.Address) ([]byte, error) { + return redStoneExecutor.abi.Pack("nonces", arg0) +} + +// UnpackNonces is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x7ecebe00. +// +// Solidity: function nonces(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) UnpackNonces(data []byte) (*big.Int, error) { + out, err := redStoneExecutor.abi.Unpack("nonces", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// RedStoneExecutorLiquidationFailed represents a LiquidationFailed event raised by the RedStoneExecutor contract. +type RedStoneExecutorLiquidationFailed struct { + Solver common.Address + Nonce *big.Int + Raw *types.Log // Blockchain specific contextual infos +} + +const RedStoneExecutorLiquidationFailedEventName = "LiquidationFailed" + +// ContractEventName returns the user-defined event name. +func (RedStoneExecutorLiquidationFailed) ContractEventName() string { + return RedStoneExecutorLiquidationFailedEventName +} + +// UnpackLiquidationFailedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event LiquidationFailed(address indexed solver, uint256 nonce) +func (redStoneExecutor *RedStoneExecutor) UnpackLiquidationFailedEvent(log *types.Log) (*RedStoneExecutorLiquidationFailed, error) { + event := "LiquidationFailed" + if log.Topics[0] != redStoneExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(RedStoneExecutorLiquidationFailed) + if len(log.Data) > 0 { + if err := redStoneExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range redStoneExecutor.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} diff --git a/api/bindings/oev/irm/AdaptiveCurveIrm.go b/api/bindings/oev/irm/AdaptiveCurveIrm.go new file mode 100644 index 00000000..d75ea232 --- /dev/null +++ b/api/bindings/oev/irm/AdaptiveCurveIrm.go @@ -0,0 +1,105 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package irm + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// Struct0 is an auto generated low-level Go binding around an user-defined struct. +type Struct0 struct { + LoanToken common.Address + CollateralToken common.Address + Oracle common.Address + Irm common.Address + Lltv *big.Int +} + +// Struct1 is an auto generated low-level Go binding around an user-defined struct. +type Struct1 struct { + TotalSupplyAssets *big.Int + TotalSupplyShares *big.Int + TotalBorrowAssets *big.Int + TotalBorrowShares *big.Int + LastUpdate *big.Int + Fee *big.Int +} + +// AdaptiveCurveIrmMetaData contains all meta data concerning the AdaptiveCurveIrm contract. +var AdaptiveCurveIrmMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[{\"name\":\"marketParams\",\"type\":\"tuple\",\"components\":[{\"name\":\"loanToken\",\"type\":\"address\"},{\"name\":\"collateralToken\",\"type\":\"address\"},{\"name\":\"oracle\",\"type\":\"address\"},{\"name\":\"irm\",\"type\":\"address\"},{\"name\":\"lltv\",\"type\":\"uint256\"}]},{\"name\":\"market\",\"type\":\"tuple\",\"components\":[{\"name\":\"totalSupplyAssets\",\"type\":\"uint128\"},{\"name\":\"totalSupplyShares\",\"type\":\"uint128\"},{\"name\":\"totalBorrowAssets\",\"type\":\"uint128\"},{\"name\":\"totalBorrowShares\",\"type\":\"uint128\"},{\"name\":\"lastUpdate\",\"type\":\"uint128\"},{\"name\":\"fee\",\"type\":\"uint128\"}]}],\"name\":\"borrowRateView\",\"outputs\":[{\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "AdaptiveCurveIrm", +} + +// AdaptiveCurveIrm is an auto generated Go binding around an Ethereum contract. +type AdaptiveCurveIrm struct { + abi abi.ABI +} + +// NewAdaptiveCurveIrm creates a new instance of AdaptiveCurveIrm. +func NewAdaptiveCurveIrm() *AdaptiveCurveIrm { + parsed, err := AdaptiveCurveIrmMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &AdaptiveCurveIrm{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *AdaptiveCurveIrm) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackBorrowRateView is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8c00bf6b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function borrowRateView((address,address,address,address,uint256) marketParams, (uint128,uint128,uint128,uint128,uint128,uint128) market) view returns(uint256) +func (adaptiveCurveIrm *AdaptiveCurveIrm) PackBorrowRateView(marketParams Struct0, market Struct1) []byte { + enc, err := adaptiveCurveIrm.abi.Pack("borrowRateView", marketParams, market) + if err != nil { + panic(err) + } + return enc +} + +// TryPackBorrowRateView is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8c00bf6b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function borrowRateView((address,address,address,address,uint256) marketParams, (uint128,uint128,uint128,uint128,uint128,uint128) market) view returns(uint256) +func (adaptiveCurveIrm *AdaptiveCurveIrm) TryPackBorrowRateView(marketParams Struct0, market Struct1) ([]byte, error) { + return adaptiveCurveIrm.abi.Pack("borrowRateView", marketParams, market) +} + +// UnpackBorrowRateView is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x8c00bf6b. +// +// Solidity: function borrowRateView((address,address,address,address,uint256) marketParams, (uint128,uint128,uint128,uint128,uint128,uint128) market) view returns(uint256) +func (adaptiveCurveIrm *AdaptiveCurveIrm) UnpackBorrowRateView(data []byte) (*big.Int, error) { + out, err := adaptiveCurveIrm.abi.Unpack("borrowRateView", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} diff --git a/api/bindings/oev/morpho/Morpho.go b/api/bindings/oev/morpho/Morpho.go new file mode 100644 index 00000000..f27249ad --- /dev/null +++ b/api/bindings/oev/morpho/Morpho.go @@ -0,0 +1,199 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package morpho + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// MorphoMetaData contains all meta data concerning the Morpho contract. +var MorphoMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[{\"type\":\"bytes32\"}],\"name\":\"market\",\"outputs\":[{\"name\":\"totalSupplyAssets\",\"type\":\"uint128\"},{\"name\":\"totalSupplyShares\",\"type\":\"uint128\"},{\"name\":\"totalBorrowAssets\",\"type\":\"uint128\"},{\"name\":\"totalBorrowShares\",\"type\":\"uint128\"},{\"name\":\"lastUpdate\",\"type\":\"uint128\"},{\"name\":\"fee\",\"type\":\"uint128\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"type\":\"bytes32\"},{\"type\":\"address\"}],\"name\":\"position\",\"outputs\":[{\"name\":\"supplyShares\",\"type\":\"uint256\"},{\"name\":\"borrowShares\",\"type\":\"uint128\"},{\"name\":\"collateral\",\"type\":\"uint128\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"type\":\"bytes32\"}],\"name\":\"idToMarketParams\",\"outputs\":[{\"name\":\"loanToken\",\"type\":\"address\"},{\"name\":\"collateralToken\",\"type\":\"address\"},{\"name\":\"oracle\",\"type\":\"address\"},{\"name\":\"irm\",\"type\":\"address\"},{\"name\":\"lltv\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "Morpho", +} + +// Morpho is an auto generated Go binding around an Ethereum contract. +type Morpho struct { + abi abi.ABI +} + +// NewMorpho creates a new instance of Morpho. +func NewMorpho() *Morpho { + parsed, err := MorphoMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &Morpho{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *Morpho) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackIdToMarketParams is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2c3c9157. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function idToMarketParams(bytes32 ) view returns(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) +func (morpho *Morpho) PackIdToMarketParams(arg0 [32]byte) []byte { + enc, err := morpho.abi.Pack("idToMarketParams", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackIdToMarketParams is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2c3c9157. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function idToMarketParams(bytes32 ) view returns(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) +func (morpho *Morpho) TryPackIdToMarketParams(arg0 [32]byte) ([]byte, error) { + return morpho.abi.Pack("idToMarketParams", arg0) +} + +// IdToMarketParamsOutput serves as a container for the return parameters of contract +// method IdToMarketParams. +type IdToMarketParamsOutput struct { + LoanToken common.Address + CollateralToken common.Address + Oracle common.Address + Irm common.Address + Lltv *big.Int +} + +// UnpackIdToMarketParams is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x2c3c9157. +// +// Solidity: function idToMarketParams(bytes32 ) view returns(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) +func (morpho *Morpho) UnpackIdToMarketParams(data []byte) (IdToMarketParamsOutput, error) { + out, err := morpho.abi.Unpack("idToMarketParams", data) + outstruct := new(IdToMarketParamsOutput) + if err != nil { + return *outstruct, err + } + outstruct.LoanToken = *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + outstruct.CollateralToken = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + outstruct.Oracle = *abi.ConvertType(out[2], new(common.Address)).(*common.Address) + outstruct.Irm = *abi.ConvertType(out[3], new(common.Address)).(*common.Address) + outstruct.Lltv = abi.ConvertType(out[4], new(big.Int)).(*big.Int) + return *outstruct, nil +} + +// PackMarket is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5c60e39a. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function market(bytes32 ) view returns(uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee) +func (morpho *Morpho) PackMarket(arg0 [32]byte) []byte { + enc, err := morpho.abi.Pack("market", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackMarket is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5c60e39a. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function market(bytes32 ) view returns(uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee) +func (morpho *Morpho) TryPackMarket(arg0 [32]byte) ([]byte, error) { + return morpho.abi.Pack("market", arg0) +} + +// MarketOutput serves as a container for the return parameters of contract +// method Market. +type MarketOutput struct { + TotalSupplyAssets *big.Int + TotalSupplyShares *big.Int + TotalBorrowAssets *big.Int + TotalBorrowShares *big.Int + LastUpdate *big.Int + Fee *big.Int +} + +// UnpackMarket is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x5c60e39a. +// +// Solidity: function market(bytes32 ) view returns(uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee) +func (morpho *Morpho) UnpackMarket(data []byte) (MarketOutput, error) { + out, err := morpho.abi.Unpack("market", data) + outstruct := new(MarketOutput) + if err != nil { + return *outstruct, err + } + outstruct.TotalSupplyAssets = abi.ConvertType(out[0], new(big.Int)).(*big.Int) + outstruct.TotalSupplyShares = abi.ConvertType(out[1], new(big.Int)).(*big.Int) + outstruct.TotalBorrowAssets = abi.ConvertType(out[2], new(big.Int)).(*big.Int) + outstruct.TotalBorrowShares = abi.ConvertType(out[3], new(big.Int)).(*big.Int) + outstruct.LastUpdate = abi.ConvertType(out[4], new(big.Int)).(*big.Int) + outstruct.Fee = abi.ConvertType(out[5], new(big.Int)).(*big.Int) + return *outstruct, nil +} + +// PackPosition is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x93c52062. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function position(bytes32 , address ) view returns(uint256 supplyShares, uint128 borrowShares, uint128 collateral) +func (morpho *Morpho) PackPosition(arg0 [32]byte, arg1 common.Address) []byte { + enc, err := morpho.abi.Pack("position", arg0, arg1) + if err != nil { + panic(err) + } + return enc +} + +// TryPackPosition is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x93c52062. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function position(bytes32 , address ) view returns(uint256 supplyShares, uint128 borrowShares, uint128 collateral) +func (morpho *Morpho) TryPackPosition(arg0 [32]byte, arg1 common.Address) ([]byte, error) { + return morpho.abi.Pack("position", arg0, arg1) +} + +// PositionOutput serves as a container for the return parameters of contract +// method Position. +type PositionOutput struct { + SupplyShares *big.Int + BorrowShares *big.Int + Collateral *big.Int +} + +// UnpackPosition is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x93c52062. +// +// Solidity: function position(bytes32 , address ) view returns(uint256 supplyShares, uint128 borrowShares, uint128 collateral) +func (morpho *Morpho) UnpackPosition(data []byte) (PositionOutput, error) { + out, err := morpho.abi.Unpack("position", data) + outstruct := new(PositionOutput) + if err != nil { + return *outstruct, err + } + outstruct.SupplyShares = abi.ConvertType(out[0], new(big.Int)).(*big.Int) + outstruct.BorrowShares = abi.ConvertType(out[1], new(big.Int)).(*big.Int) + outstruct.Collateral = abi.ConvertType(out[2], new(big.Int)).(*big.Int) + return *outstruct, nil +} diff --git a/api/bindings/oev/oracle/MorphoOracle.go b/api/bindings/oev/oracle/MorphoOracle.go new file mode 100644 index 00000000..eb749d42 --- /dev/null +++ b/api/bindings/oev/oracle/MorphoOracle.go @@ -0,0 +1,86 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package oracle + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// MorphoOracleMetaData contains all meta data concerning the MorphoOracle contract. +var MorphoOracleMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[],\"name\":\"price\",\"outputs\":[{\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "MorphoOracle", +} + +// MorphoOracle is an auto generated Go binding around an Ethereum contract. +type MorphoOracle struct { + abi abi.ABI +} + +// NewMorphoOracle creates a new instance of MorphoOracle. +func NewMorphoOracle() *MorphoOracle { + parsed, err := MorphoOracleMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &MorphoOracle{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *MorphoOracle) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackPrice is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xa035b1fe. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function price() view returns(uint256) +func (morphoOracle *MorphoOracle) PackPrice() []byte { + enc, err := morphoOracle.abi.Pack("price") + if err != nil { + panic(err) + } + return enc +} + +// TryPackPrice is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xa035b1fe. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function price() view returns(uint256) +func (morphoOracle *MorphoOracle) TryPackPrice() ([]byte, error) { + return morphoOracle.abi.Pack("price") +} + +// UnpackPrice is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xa035b1fe. +// +// Solidity: function price() view returns(uint256) +func (morphoOracle *MorphoOracle) UnpackPrice(data []byte) (*big.Int, error) { + out, err := morphoOracle.abi.Unpack("price", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} diff --git a/api/graphql/morpho/README.md b/api/graphql/morpho/README.md new file mode 100644 index 00000000..cdc6c4df --- /dev/null +++ b/api/graphql/morpho/README.md @@ -0,0 +1,27 @@ +# Morpho GraphQL + +This directory is the contract-of-record for the generated Morpho GraphQL binding. + +- `schema.graphql` is the full Morpho GraphQL schema SDL fetched from the configured endpoint. +- `operations/*.graphql` contains the operations this repo actually calls. +- `operations.json` is generated by `genqlient` and records the exact query strings sent on the wire. +- `../../morphographql/generated.go` is generated Go code. Do not edit it by hand. + +To add a Morpho API read for any solver, add a named operation under `operations/`, then run: + +```bash +make refresh-morpho-graphql-client +``` + +To refresh the upstream schema, run: + +```bash +make refresh-morpho-graphql-schema +make refresh-morpho-graphql-client +``` + +This mirrors Morpho's TypeScript `@morpho-org/blue-api-sdk` pattern: the full schema is vendored, while +typed bindings are generated from explicit operation documents. + +Custom GraphQL scalars are bound to strings at the generated boundary (`Address`, `MarketId`, `BigInt`, +`HexString`). Solver-local adapters parse them into addresses, hashes, or integers after validation. diff --git a/api/graphql/morpho/genqlient.yaml b/api/graphql/morpho/genqlient.yaml new file mode 100644 index 00000000..0846b715 --- /dev/null +++ b/api/graphql/morpho/genqlient.yaml @@ -0,0 +1,16 @@ +schema: schema.graphql +operations: + - operations/*.graphql +generated: ../../morphographql/generated.go +export_operations: operations.json +package: morphographql +optional: pointer +bindings: + Address: + type: string + BigInt: + type: github.com/symbioticfi/vault-solver/api/morphographql/scalars.BigIntString + HexString: + type: string + MarketId: + type: string diff --git a/api/graphql/morpho/operations.json b/api/graphql/morpho/operations.json new file mode 100644 index 00000000..977b233d --- /dev/null +++ b/api/graphql/morpho/operations.json @@ -0,0 +1,14 @@ +{ + "operations": [ + { + "operationName": "MorphoDiscoverMarkets", + "query": "\nquery MorphoDiscoverMarkets ($loan: [String!]!, $coll: [String!]!, $chains: [Int!]!, $first: Int!) {\n\tmarkets(first: $first, where: {loanAssetAddress_in:$loan,collateralAssetAddress_in:$coll,chainId_in:$chains}) {\n\t\titems {\n\t\t\tmarketId\n\t\t\toracleAddress\n\t\t\tirmAddress\n\t\t\tlltv\n\t\t\tloanAsset {\n\t\t\t\taddress\n\t\t\t}\n\t\t\tcollateralAsset {\n\t\t\t\taddress\n\t\t\t}\n\t\t\tstate {\n\t\t\t\tblockNumber\n\t\t\t\tborrowAssets\n\t\t\t\tborrowShares\n\t\t\t\tsupplyAssets\n\t\t\t\tsupplyShares\n\t\t\t\ttimestamp\n\t\t\t\tprice\n\t\t\t}\n\t\t}\n\t}\n}\n", + "sourceLocation": "operations/discovery.graphql" + }, + { + "operationName": "MorphoPositionsByMarket", + "query": "\nquery MorphoPositionsByMarket ($ids: [String!]!, $first: Int!, $skip: Int!, $maxHf: Float) {\n\tmarketPositions(first: $first, skip: $skip, orderBy: HealthFactor, orderDirection: Asc, where: {marketUniqueKey_in:$ids,healthFactor_lte:$maxHf}) {\n\t\titems {\n\t\t\tuser {\n\t\t\t\taddress\n\t\t\t}\n\t\t\tmarket {\n\t\t\t\tmarketId\n\t\t\t}\n\t\t\tstate {\n\t\t\t\tborrowShares\n\t\t\t\tcollateral\n\t\t\t}\n\t\t\thealthFactor\n\t\t}\n\t}\n}\n", + "sourceLocation": "operations/discovery.graphql" + } + ] +} \ No newline at end of file diff --git a/api/graphql/morpho/operations/discovery.graphql b/api/graphql/morpho/operations/discovery.graphql new file mode 100644 index 00000000..fdb295c7 --- /dev/null +++ b/api/graphql/morpho/operations/discovery.graphql @@ -0,0 +1,52 @@ +query MorphoDiscoverMarkets($loan: [String!]!, $coll: [String!]!, $chains: [Int!]!, $first: Int!) { + markets( + first: $first + where: { loanAssetAddress_in: $loan, collateralAssetAddress_in: $coll, chainId_in: $chains } + ) { + items { + marketId + oracleAddress + irmAddress + lltv + loanAsset { + address + } + collateralAsset { + address + } + state { + blockNumber + borrowAssets + borrowShares + supplyAssets + supplyShares + timestamp + price + } + } + } +} + +query MorphoPositionsByMarket($ids: [String!]!, $first: Int!, $skip: Int!, $maxHf: Float) { + marketPositions( + first: $first + skip: $skip + orderBy: HealthFactor + orderDirection: Asc + where: { marketUniqueKey_in: $ids, healthFactor_lte: $maxHf } + ) { + items { + user { + address + } + market { + marketId + } + state { + borrowShares + collateral + } + healthFactor + } + } +} diff --git a/api/graphql/morpho/schema.graphql b/api/graphql/morpho/schema.graphql new file mode 100644 index 00000000..25cd5e1a --- /dev/null +++ b/api/graphql/morpho/schema.graphql @@ -0,0 +1,5854 @@ +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include( +""" +Included when true. +""" + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip( +""" +Skipped when true. +""" + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +""" +Marks an element of a GraphQL schema as no longer supported. +""" +directive @deprecated( +""" +Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/). +""" + reason: String +) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE + +""" +Exposes a URL that specifies the behavior of this scalar. +""" +directive @specifiedBy( +""" +The URL that specifies the behavior of this scalar. +""" + url: String! +) on SCALAR + +""" +Indicates exactly one field must be supplied and this field must not be `null`. +""" +directive @oneOf on INPUT_OBJECT + +directive @cacheControl( + maxAge: Int + scope: CacheControlScope + inheritMaxAge: Boolean +) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | QUERY + +""" +Define a relation between the field and other nodes +""" +directive @complexity( +""" +The complexity value for the field +""" + value: Int! + multipliers: [String!] +) on FIELD_DEFINITION + +type PageInfo { +""" +Total number of items +""" + countTotal: Int! +""" +Number of items as scoped by pagination. +""" + count: Int! +""" +Number of items requested. +""" + limit: Int! +""" +Number of items skipped. +""" + skip: Int! +} + +""" +The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. +""" +scalar Int + +type AddressMetadata { + type: AddressMetadataType! + metadata: Metadata! +} + +enum AddressMetadataType { + safe + aragon +} + +union Metadata =SafeAddressMetadata | AragonAddressMetadata + +""" +Safe address metadata +""" +type SafeAddressMetadata { + owners: [String!]! + threshold: Int! +} + +""" +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. +""" +scalar String + +""" +Aragon address metadata +""" +type AragonAddressMetadata { + ensDomain: String + name: String + description: String +} + +type PaginatedAddressMetadata { + items: [AddressMetadata!] + pageInfo: PageInfo +} + +""" +Account +""" +type Account { +""" +Account adress. +""" + address: Address! +""" +Additional information about the account. +""" + metadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata! +} + +""" +42 character long hex address +""" +scalar Address + +""" +Asset yield +""" +type AssetYield { +""" +Asset yield (APR) +""" + apr: Float! +""" +Lookback period used to compute the APR, in seconds. +""" + lookback: Int! +} + +""" +The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). +""" +scalar Float + +""" +Asset price +""" +type AssetPrice { +""" +Asset price in USD, for display purpose. +""" + usd: Float! +""" +Timestamp of the price returned. +""" + timestamp: BigInt! +} + +""" +The `BigInt` scalar type represents non-fractional signed whole numeric values. +""" +scalar BigInt + +""" +Asset +""" +type Asset implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! +""" +ERC-20 token contract address +""" + address: Address! + decimals: Float! + name: String! + symbol: String! + tags: [String!] +""" +Token logo URI, for display purpose +""" + logoURI: String +""" +Either the asset is listed or not +""" + isListed: Boolean! +""" +Either the asset is whitelisted or not +""" + isWhitelisted: Boolean! @deprecated(reason: "Use isListed instead.") +""" +Current price in USD together with the timestamp of the price returned. +""" + price( +""" +Maximum lookback in hours when resolving the latest available price. Accepted range: 0-24. +""" + maxLag: Int + ): AssetPrice +""" +Current price in USD, for display purpose. +""" + priceUsd: Float @deprecated(reason: "Use price.usd instead.") +""" +Historical price in USD, for display purpose +""" + historicalPriceUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Current spot price in ETH. +""" + spotPriceEth( + timestamp: Float + ): Float @deprecated(reason: "Use historicalPriceUsd instead.") +""" +ERC-20 token total supply +""" + totalSupply: BigInt! @deprecated(reason: "Deprecated.") +""" +Historical spot price in ETH +""" + historicalSpotPriceEth( + options: TimeseriesOptions + ): [FloatDataPoint!]! @deprecated(reason: "Use historicalPriceUsd instead.") + oraclePriceUsd( + timestamp: Float + ): Float @deprecated(reason: "Use price.usd instead.") +""" +Morpho Vault V1 +""" + vault: Vault + yield: AssetYield +} + +interface ChainReference { + chain: Chain! +} + +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. +""" +scalar ID + +""" +The `Boolean` scalar type represents `true` or `false`. +""" +scalar Boolean + +input TimeseriesOptions { + startTimestamp: Int + endTimestamp: Int + interval: TimeseriesInterval +} + +enum TimeseriesInterval { + MINUTE @deprecated(reason: "Deprecated.") + FIVE_MINUTES @deprecated(reason: "Deprecated.") + FIFTEEN_MINUTES @deprecated(reason: "Deprecated.") + HALF_HOUR @deprecated(reason: "Deprecated.") + HOUR + DAY + WEEK + MONTH + QUARTER + YEAR + ALL @deprecated(reason: "Use startTimestamp and endTimestamp instead.") +} + +""" +Vault Liquidity +""" +type VaultLiquidity { +""" +Vault withdrawable liquidity in underlying. +""" + underlying: BigInt! +""" +Vault withdrawable liquidity in USD. +""" + usd: Float! +} + +""" +Vault allocator +""" +type VaultAllocator { +""" +Allocator address. +""" + address: Address! +""" +Allocator since block number +""" + blockNumber: BigInt! +""" +Allocator since timestamp +""" + timestamp: BigInt! +""" +Additional information about the address. +""" + metadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +} + +""" +Vault metadata +""" +type VaultMetadata { + description: String! + image: String! + forumLink: String @deprecated(reason: "Deprecated and always returns null.") +} + +""" +MetaMorpho Vaults +""" +type Vault implements AssetReference & ChainReference{ +""" +The asset. +""" + asset: Asset! +""" +The chain on which the entity is deployed. +""" + chain: Chain! + address: Address! + symbol: String! + creationBlockNumber: Int! + creationTimestamp: BigInt! + creatorAddress: Address + id: ID! @deprecated(reason: "Use address and chainId instead.") +""" +The vault's displayed name. +""" + name: String! +""" +A vault V1 is listed as soon as it is promoted OR is listed as an underlying vault of a vault v2 (via a MorphoVaultV1Adapter). +""" + listed: Boolean! +""" +Curated listing history for this vault: the chronological sequence of `Added` and `Removed` transitions from morpho-blue-api-metadata. Empty if the vault was never listed. +""" + listingHistory: [VaultListingHistoryEvent!]! +""" +A vault V1 is featured via internal, manual review. +""" + featured: Boolean! +""" +The vault's factory. +""" + factory: VaultFactory! +""" +The current state of the vault. +""" + state: VaultState +""" +The historical state of the vault. +""" + historicalState: VaultHistory! + liquidity: VaultLiquidity + warnings: [VaultWarning!]! +""" +Public allocator configuration +""" + publicAllocatorConfig: PublicAllocatorConfig +""" +Vault allocators +""" + allocators: [VaultAllocator!]! +""" +Vault admin events on the vault +""" + adminEvents( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: VaultAdminEventsFilters + ): PaginatedVaultAdminEvent + metadata: VaultMetadata +} + +interface AssetReference { + asset: Asset! +} + +""" +Filtering options for vault admin events. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultAdminEventsFilters { +""" +Filtering options for vault admin events. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [String!] +} + +type PaginatedAssets { + items: [Asset!] + pageInfo: PageInfo +} + +type BigIntDataPoint { + x: Float! + y: BigInt +} + +type FloatDataPoint { + x: Float! + y: Float +} + +type IntDataPoint { + x: Float! + y: Int +} + +""" +Block +""" +type Block { + id: ID! + number: BigInt! + timestamp: BigInt! +} + +""" +Chain +""" +type Chain { + id: Int! + network: String! + currency: String! +""" +Block time in milliseconds +""" + blockTimeMs: Int +""" +Latest block of the chain +""" + headBlock: Block +} + +""" +Vault curator state +""" +type CuratorState { + curatorId: ID! +""" +Assets Under Management. Total assets managed by the curator, in USD for display purpose. +""" + aum: Float! +} + +""" +Curator Address +""" +type CuratorAddress { + chainId: Int! + address: String! +""" +Additional information about the address. +""" + metadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +} + +""" +Vault curator +""" +type Curator { + id: ID! + name: String! + description: String + verified: Boolean! +""" +Curator logo URI, for display purpose +""" + image: String +""" +Link to curator website +""" + url: String @deprecated(reason: "Use socials instead.") + socials: [CuratorSocial!]! + addresses: [CuratorAddress!]! + ownerOnly: Boolean! +""" +Current state +""" + state: CuratorState +} + +type CuratorSocial { + type: String! + url: String! +} + +type PaginatedCurators { + items: [Curator!] + pageInfo: PageInfo +} + +""" +Morpho Blue market state rewards +""" +type MarketStateReward implements AssetReference{ +""" +The asset. +""" + asset: Asset! +""" +Amount of reward tokens per year on the supply side. Scaled to reward asset decimals. +""" + yearlySupplyTokens: BigInt! @deprecated(reason: "Deprecated.") +""" +Amount of reward tokens per year on the borrow side. Scaled to reward asset decimals. +""" + yearlyBorrowTokens: BigInt! @deprecated(reason: "Deprecated.") +""" +Supply rewards APR. +""" + supplyApr: Float +""" +Borrow rewards APR. +""" + borrowApr: Float +""" +Amount of reward tokens per supplied token (annualized). Scaled to reward asset decimals. +""" + amountPerSuppliedToken: BigInt! @deprecated(reason: "Deprecated.") +""" +Amount of reward tokens per borrowed token (annualized). Scaled to reward asset decimals. +""" + amountPerBorrowedToken: BigInt! @deprecated(reason: "Deprecated.") + id: ID! +} + +""" +Morpho Blue market state +""" +type MarketState { +""" +Block number of the state +""" + blockNumber: BigInt! +""" +Amount borrowed on the market, in underlying units. Amount increases as interests accrue. +""" + borrowAssets: BigInt! +""" +Amount supplied on the market, in underlying units. Amount increases as interests accrue. +""" + supplyAssets: BigInt! +""" +Amount borrowed on the market, in USD for display purpose +""" + borrowAssetsUsd: Float +""" +Amount supplied on the market, in USD for display purpose +""" + supplyAssetsUsd: Float +""" +Amount borrowed on the market, in market share units. Amount does not increase as interest accrue. +""" + borrowShares: BigInt! +""" +Amount supplied on the market, in market share units. Amount does not increase as interest accrue. +""" + supplyShares: BigInt! +""" +Amount of collateral in the market, in underlying units +""" + collateralAssets: BigInt +""" +Amount of collateral in the market, in USD for display purpose +""" + collateralAssetsUsd: Float +""" +Utilization rate +""" + utilization: Float! +""" +Apy at target utilization +""" + apyAtTarget: Float! +""" +Rate at target utilization +""" + rateAtTarget: BigInt +""" +Instantaneous Supply APY +""" + supplyApy: Float! +""" +Instantaneous Borrow APY +""" + borrowApy: Float! +""" +Instantaneous Supply APY including rewards +""" + netSupplyApy: Float +""" +Instantaneous Borrow APY including rewards +""" + netBorrowApy: Float +""" +Last update timestamp. +""" + timestamp: BigInt! +""" +6h average supply APY excluding rewards (6h timeframe is subject to change). +""" + avgSupplyApy: Float +""" +6h average borrow APY excluding rewards (6h timeframe is subject to change). +""" + avgBorrowApy: Float +""" +Daily Supply APY excluding rewards +""" + dailySupplyApy: Float +""" +Daily Borrow APY excluding rewards +""" + dailyBorrowApy: Float + id: ID! +""" +Block information +""" + block: Block! +""" +Collateral price +""" + price: BigInt +""" +Market state rewards +""" + rewards: [MarketStateReward!]! +""" +Market collateral price change percentage (24h). Null if there is no historical data +""" + dailyPriceVariation: Float +""" +Fee rate +""" + fee: Float! +""" +Amount available to borrow on the market, in underlying units +""" + liquidityAssets: BigInt! +""" +Amount available to borrow on the market, in USD for display purpose +""" + liquidityAssetsUsd: Float +""" +Total size of the market. This is the sum of all assets that are allocated or can be reallocated to this market. +""" + size: BigInt! +""" +Total size of the market. This is the sum of all assets that are allocated or can be reallocated to this market, in USD for display purpose. +""" + sizeUsd: Float +""" +Amount available to borrow on the market, including shared liquidity. +""" + totalLiquidity: BigInt! +""" +Amount available to borrow on the market, including shared liquidity, in USD for display purpose. +""" + totalLiquidityUsd: Float +""" +6h average supply APY including rewards (6h timeframe is subject to change). +""" + avgNetSupplyApy: Float +""" +6h average borrow APY including rewards (6h timeframe is subject to change). +""" + avgNetBorrowApy: Float +""" +Daily Supply APY including rewards +""" + dailyNetSupplyApy: Float +""" +Daily Borrow APY including rewards +""" + dailyNetBorrowApy: Float +""" +Weekly Supply APY excluding rewards +""" + weeklySupplyApy: Float +""" +Weekly Supply APY including rewards +""" + weeklyNetSupplyApy: Float +""" +Weekly Borrow APY excluding rewards +""" + weeklyBorrowApy: Float +""" +Weekly Borrow APY including rewards +""" + weeklyNetBorrowApy: Float +""" +Biweekly Supply APY excluding rewards +""" + biweeklySupplyApy: Float +""" +Biweekly Supply APY including rewards +""" + biweeklyNetSupplyApy: Float +""" +Biweekly Borrow APY excluding rewards +""" + biweeklyBorrowApy: Float +""" +Biweekly Borrow APY including rewards +""" + biweeklyNetBorrowApy: Float +""" +Monthly Supply APY excluding rewards +""" + monthlySupplyApy: Float +""" +Monthly Supply APY including rewards +""" + monthlyNetSupplyApy: Float +""" +Monthly Borrow APY excluding rewards +""" + monthlyBorrowApy: Float +""" +Monthly Borrow APY including rewards +""" + monthlyNetBorrowApy: Float +""" +Quarterly Supply APY excluding rewards +""" + quarterlySupplyApy: Float +""" +Quarterly Supply APY including rewards +""" + quarterlyNetSupplyApy: Float +""" +Quarterly Borrow APY excluding rewards +""" + quarterlyBorrowApy: Float +""" +Quarterly Borrow APY including rewards +""" + quarterlyNetBorrowApy: Float +""" +Yearly Supply APY excluding rewards +""" + yearlySupplyApy: Float +""" +Yearly Supply APY including rewards +""" + yearlyNetSupplyApy: Float +""" +Yearly Borrow APY excluding rewards +""" + yearlyBorrowApy: Float +""" +Yearly Borrow APY including rewards +""" + yearlyNetBorrowApy: Float +""" +All Time Supply APY excluding rewards +""" + allTimeSupplyApy: Float +""" +All Time Supply APY including rewards +""" + allTimeNetSupplyApy: Float +""" +All Time Borrow APY excluding rewards +""" + allTimeBorrowApy: Float +""" +All Time Borrow APY including rewards +""" + allTimeNetBorrowApy: Float +} + +""" +Market state history +""" +type MarketHistory { + id: ID! +""" +Amount borrowed on the market, in underlying units. Amount increases as interests accrue. +""" + borrowAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount supplied on the market, in underlying units. Amount increases as interests accrue. +""" + supplyAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount borrowed on the market, in USD for display purpose +""" + borrowAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount supplied on the market, in USD for display purpose +""" + supplyAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount borrowed on the market, in market share units. Amount does not increase as interest accrue. +""" + borrowShares( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount supplied on the market, in market share units. Amount does not increase as interest accrue. +""" + supplyShares( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Utilization rate +""" + utilization( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount available to borrow on the market, in underlying units +""" + liquidityAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount available to borrow on the market, in USD for display purpose +""" + liquidityAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount of collateral in the market, in underlying units +""" + collateralAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount of collateral in the market, in USD for display purpose +""" + collateralAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +AdaptiveCurveIRM rate per second if utilization was at target +""" + rateAtTarget( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +AdaptiveCurveIRM APY if utilization was at target +""" + apyAtTarget( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Supply APY excluding rewards +""" + supplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Supply APY including rewards +""" + netSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Borrow APY including rewards +""" + netBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Borrow APY excluding rewards +""" + borrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Fee rate +""" + fee( + options: TimeseriesOptions + ): [FloatDataPoint!] @deprecated(reason: "Deprecated.") +""" +Collateral price +""" + price( + options: TimeseriesOptions + ): [FloatDataPoint!]! @deprecated(reason: "Deprecated.") +""" +Daily Supply APY excluding rewards +""" + dailySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Supply APY including rewards +""" + dailyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Borrow APY excluding rewards +""" + dailyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Borrow APY including rewards +""" + dailyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Supply APY excluding rewards +""" + weeklySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Supply APY including rewards +""" + weeklyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Borrow APY excluding rewards +""" + weeklyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Borrow APY including rewards +""" + weeklyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Supply APY excluding rewards +""" + monthlySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Supply APY including rewards +""" + monthlyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Borrow APY excluding rewards +""" + monthlyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Borrow APY including rewards +""" + monthlyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Supply APY excluding rewards +""" + quarterlySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Supply APY including rewards +""" + quarterlyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Borrow APY excluding rewards +""" + quarterlyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Borrow APY including rewards +""" + quarterlyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Supply APY excluding rewards +""" + yearlySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Supply APY including rewards +""" + yearlyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Borrow APY excluding rewards +""" + yearlyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Borrow APY including rewards +""" + yearlyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +All Time Supply APY excluding rewards +""" + allTimeSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +All Time Borrow APY excluding rewards +""" + allTimeBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +} + +""" +Morpho Blue state history +""" +type MorphoBlueStateHistory { +""" +Amount of collateral in all markets, in USD for display purpose. +""" + totalCollateralUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount supplied in all markets, in USD for display purpose +""" + totalSupplyUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount deposited in all markets, in USD for display purpose +""" + totalDepositUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount borrowed in all markets, in USD for display purpose +""" + totalBorrowUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +TVL (collateral + supply - borrows), in USD for display purpose +""" + tvlUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Number of unique users that have interacted with the protocol +""" + userCount( + options: TimeseriesOptions + ): [IntDataPoint!]! +""" +Number of markets in the protocol +""" + marketCount( + options: TimeseriesOptions + ): [IntDataPoint!]! +""" +Number of meta morpho vaults in the protocol +""" + vaultCount( + options: TimeseriesOptions + ): [IntDataPoint!]! +} + +""" +Morpho Blue global state +""" +type MorphoBlueState { + id: ID! +""" +Last update timestamp. +""" + timestamp: BigInt! +""" +Amount of collateral in all markets, in USD for display purpose +""" + totalCollateralUsd: Float! +""" +Amount supplied in all markets, in USD for display purpose +""" + totalSupplyUsd: Float! +""" +Amount deposited in all markets, in USD for display purpose +""" + totalDepositUsd: Float! +""" +Amount borrowed in all markets, in USD for display purpose +""" + totalBorrowUsd: Float! +""" +TVL (collateral + supply - borrows), in USD for display purpose +""" + tvlUsd: Float! +""" +Number of unique users that have interacted with the protocol +""" + userCount: Int! +""" +Number of markets in the protocol +""" + marketCount: Int! +""" +Number of meta morpho vaults in the protocol +""" + vaultCount: Int! +} + +""" +Morpho Blue deployment +""" +type MorphoBlue implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: Int! +""" +Current state +""" + state: MorphoBlueState +""" +State history +""" + historicalState: MorphoBlueStateHistory +} + +""" +Oracle creation tx +""" +type ChainlinkOracleV2Event { + txHash: HexString! + timestamp: BigInt! + blockNumber: BigInt! + chainId: Int! +""" +Transaction caller address +""" + caller: Address! +} + +""" +Hexadecimal string +""" +scalar HexString + +""" +Oracle Feed +""" +type OracleFeed implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! +""" +Feed contract address +""" + address: Address! + description: String @deprecated(reason: "Deprecated.") + vendor: String @deprecated(reason: "Deprecated.") + pair: [String!] @deprecated(reason: "Deprecated.") + decimals: Int + historicalPrice: [BigIntDataPoint!] @deprecated(reason: "Deprecated.") + price: BigIntDataPoint @deprecated(reason: "Deprecated.") +} + +""" +Oracle Vault +""" +type OracleVault implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! +""" +Vault contract address +""" + address: Address! + vendor: String @deprecated(reason: "Deprecated.") + pair: [String!] @deprecated(reason: "Deprecated.") + decimals: Int @deprecated(reason: "Deprecated.") + price: BigIntDataPoint + historicalPrice: [BigIntDataPoint!] +""" +Underlying asset id. +""" + assetId: String +""" +Linked vault id. +""" + metamorphoId: String +} + +""" +Oracle +""" +type Oracle implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! +""" +Oracle contract address +""" + address: Address! +""" +Oracle type +""" + type: OracleType! + data: OracleData + creationEvent: ChainlinkOracleV2Event + markets: [Market!]! +} + +enum OracleType { + ChainlinkOracle + ChainlinkOracleV2 + CustomOracle + Unknown +} + +union OracleData =MorphoChainlinkOracleData | MorphoChainlinkOracleV2Data + +""" +Morpho chainlink oracle data +""" +type MorphoChainlinkOracleData { + baseFeedOne: OracleFeed + baseFeedTwo: OracleFeed + quoteFeedOne: OracleFeed + quoteFeedTwo: OracleFeed + scaleFactor: BigInt! + chainId: Int! + baseOracleVault: OracleVault + vaultConversionSample: BigInt! +} + +""" +Morpho chainlink oracle v2 data +""" +type MorphoChainlinkOracleV2Data { + baseFeedOne: OracleFeed + baseFeedTwo: OracleFeed + quoteFeedOne: OracleFeed + quoteFeedTwo: OracleFeed + scaleFactor: BigInt! + baseOracleVault: OracleVault + quoteOracleVault: OracleVault + chainId: Int! + baseVaultConversionSample: BigInt! + quoteVaultConversionSample: BigInt! +} + +""" +Public allocator shared liquidity +""" +type PublicAllocatorSharedLiquidity { + assets: BigInt! + id: ID! + publicAllocator: PublicAllocator! + withdrawMarket: Market! + supplyMarket: Market! + vault: Vault! +} + +""" +MetaMorpho vault state rewards +""" +type VaultStateReward implements AssetReference{ +""" +The asset. +""" + asset: Asset! +""" +Amount of reward tokens distributed to MetaMorpho vault suppliers (annualized). Scaled to reward asset decimals. +""" + yearlySupplyTokens: BigInt! @deprecated(reason: "Deprecated.") +""" +Rewards APR. +""" + supplyApr: Float +""" +Amount of reward tokens earned per supplied token (annualized). Scaled to reward asset decimals. +""" + amountPerSuppliedToken: BigInt! @deprecated(reason: "Deprecated.") +} + +""" +Market position +""" +type MarketPosition { + id: ID! +""" +Health factor of the position, computed as collateral value divided by borrow value. +""" + healthFactor: Float + listed: Boolean! +""" +Price variation required for the given position to reach its liquidation threshold (scaled by WAD) +""" + priceVariationToLiquidationPrice: Float + market: Market! + user: User! +""" +Current state +""" + state: MarketPositionState +""" +State history +""" + historicalState: MarketPositionHistory +} + +type PaginatedMarketPositions { + items: [MarketPosition!] + pageInfo: PageInfo +} + +""" +MetaMorpho vault position +""" +type VaultPosition { + id: ID! + listed: Boolean! + vault: Vault! + user: User! +""" +Current state +""" + state: VaultPositionState + historicalState: VaultPositionHistory +} + +type MetaMorphoAdapterFactory implements VaultV2AdapterFactory & ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: BigInt! +} + +interface VaultV2AdapterFactory { + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: BigInt! +} + +type MetaMorphoAdapter implements VaultV2Adapter & ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! +""" +Block number at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationBlockNumber: BigInt! +""" +Timestamp at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationTimestamp: BigInt! + type: VaultV2AdapterType! + vault: VaultV2! + factory: VaultV2AdapterFactory! +""" +The assets managed by the adapter (includes virtually accrued interest). +""" + assets: BigInt! +""" +The USD value of assets managed by the adapter (includes virtually accrued interest). +""" + assetsUsd: Float +""" +The current active force deallocate penalty for this adapter. Returns 0 if unset. +""" + forceDeallocatePenalty: BigInt! + metaMorpho: Vault! + position: VaultPosition +} + +interface VaultV2Adapter { + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: BigInt! + creationTimestamp: BigInt! + type: VaultV2AdapterType! + vault: VaultV2! + factory: VaultV2AdapterFactory! + assets: BigInt! + assetsUsd: Float + forceDeallocatePenalty: BigInt! +} + +enum VaultV2AdapterType { + MetaMorpho + MorphoVaultV2 + MorphoMarketV1 +} + +type MorphoVaultV2Adapter implements VaultV2Adapter & ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! +""" +Block number at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationBlockNumber: BigInt! +""" +Timestamp at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationTimestamp: BigInt! + type: VaultV2AdapterType! + vault: VaultV2! + factory: VaultV2AdapterFactory! +""" +The assets managed by the adapter (includes virtually accrued interest). +""" + assets: BigInt! +""" +The USD value of assets managed by the adapter (includes virtually accrued interest). +""" + assetsUsd: Float +""" +The current active force deallocate penalty for this adapter. Returns 0 if unset. +""" + forceDeallocatePenalty: BigInt! +""" +The inner VaultV2 that this adapter wraps. +""" + innerVault: VaultV2! +} + +type MorphoMarketV1Adapter implements VaultV2Adapter & ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! +""" +Block number at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationBlockNumber: BigInt! +""" +Timestamp at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationTimestamp: BigInt! + type: VaultV2AdapterType! + vault: VaultV2! + factory: VaultV2AdapterFactory! +""" +The assets managed by the adapter (includes virtually accrued interest). +""" + assets: BigInt! +""" +The USD value of assets managed by the adapter (includes virtually accrued interest). +""" + assetsUsd: Float +""" +The current active force deallocate penalty for this adapter. Returns 0 if unset. +""" + forceDeallocatePenalty: BigInt! + positions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedMarketPositions! +} + +type PaginatedVaultV2Adapters { + items: [VaultV2Adapter!] + pageInfo: PageInfo +} + +""" +Market parameters +""" +type MarketParams { + id: HexString! + loanToken: Address! + collateralToken: Address! + oracle: Address! + irm: Address! + lltv: BigInt! +} + +type VaultV2CapConfig { + id: HexString! + idData: HexString! + type: VaultV2CapType! + data: VaultV2CapData +} + +enum VaultV2CapType { + Adapter + Collateral + MarketV1 + Unknown +} + +union VaultV2CapData =AdapterCapData | CollateralCapData | MarketV1CapData + +""" +Adapter cap data +""" +type AdapterCapData implements ActiveAdapterData{ + adapterAddress: Address! +""" +The adapter. Null if the adapter is not recognized. +""" + adapter: VaultV2Adapter +} + +interface ActiveAdapterData { + adapterAddress: Address! + adapter: VaultV2Adapter +} + +""" +Collateral cap data +""" +type CollateralCapData { + collateralAddress: Address! +""" +The collateral asset to which this cap is associated. Null if the asset is not recognized. +""" + collateralToken: Asset +} + +""" +Market V1 cap data +""" +type MarketV1CapData implements ActiveAdapterData{ + adapterAddress: Address! +""" +The adapter. Null if the adapter is not recognized. +""" + adapter: VaultV2Adapter + marketParams: MarketParams! +""" +The market to which this cap is associated. Null if the market is not recognized. +""" + market: Market +} + +""" +Vault V2 caps +""" +type VaultV2Caps { + id: HexString! + idData: HexString! + type: VaultV2CapType! + data: VaultV2CapData + absoluteCap: BigInt! + relativeCap: BigInt! +""" +Assets allocation of the Cap. Note that the allocation is not always up to date, because interest and losses are accounted only when (de)allocating in the corresponding adapters. +""" + allocation: BigInt! +} + +type PaginatedVaultV2Caps { + items: [VaultV2Caps!] + pageInfo: PageInfo +} + +""" +Transaction +""" +type Transaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + timestamp: BigInt! + hash: HexString! + logIndex: Int! + blockNumber: BigInt! + type: TransactionType! + data: TransactionData! + user: User! +} + +enum TransactionType { + MetaMorphoDeposit + MetaMorphoWithdraw + MetaMorphoTransfer + MetaMorphoFee + MarketBorrow + MarketLiquidation + MarketRepay + MarketSupply + MarketSupplyCollateral + MarketWithdraw + MarketWithdrawCollateral +} + +union TransactionData =VaultTransactionData | MarketCollateralTransferTransactionData | MarketTransferTransactionData | MarketLiquidationTransactionData + +""" +Morpho Vault V1 transaction data +""" +type VaultTransactionData { + shares: BigInt! + assets: BigInt! + timestamp: BigInt! + vault: Vault! + assetsUsd: Float +} + +""" +Market collateral transfer transaction data +""" +type MarketCollateralTransferTransactionData { + assets: BigInt! + timestamp: BigInt! + market: Market! + assetsUsd: Float +} + +""" +Market transfer transaction data +""" +type MarketTransferTransactionData { + shares: BigInt! + assets: BigInt! + timestamp: BigInt! + market: Market! + assetsUsd: Float +} + +""" +Market liquidation transaction data +""" +type MarketLiquidationTransactionData { + repaidAssets: BigInt! + repaidShares: BigInt! + seizedAssets: BigInt! + badDebtShares: BigInt! + badDebtAssets: BigInt! + liquidator: Address! + timestamp: BigInt! + market: Market! + repaidAssetsUsd: Float + seizedAssetsUsd: Float + badDebtAssetsUsd: Float +} + +""" +User state history +""" +type UserHistory { +""" +Total value of all the user's vault positions, in USD. +""" + vaultsAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total value of all the user's VaultV2 positions, in USD. +""" + vaultV2sAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total collateral of all the user's market positions, in USD. +""" + marketsCollateralUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total supply assets of all the user's market positions, in USD. +""" + marketsSupplyAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total borrow assets of all the user's market positions, in USD. +""" + marketsBorrowAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total margin of all the user's market positions, in USD. +""" + marketsMarginUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +} + +""" +User state +""" +type UserState { +""" +Total value of all the user's vault positions, in USD. +""" + vaultsAssetsUsd: Float! +""" +Total value of all the user's VaultV2 positions, in USD. +""" + vaultV2sAssetsUsd: Float! +""" +Total collateral value of all the user's market positions, in USD. +""" + marketsCollateralUsd: Float! +""" +Total supply assets value of all the user's market positions, in USD. +""" + marketsSupplyAssetsUsd: Float! +""" +Total borrow assets value of all the user's market positions, in USD. +""" + marketsBorrowAssetsUsd: Float! +""" +Total margin (collateral - borrow) of all the user's market positions, in USD. +""" + marketsMarginUsd: Float! +} + +""" +User +""" +type User implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! @deprecated(reason: "Use address and chainId instead.") + address: Address! + tag: String @deprecated(reason: "Deprecated.") + marketPositions: [MarketPosition!]! + vaultPositions: [VaultPosition!]! + vaultV2Positions: [VaultV2Position!]! + transactions: [Transaction!]! @deprecated(reason: "Use vaultV1Transactions or marketTransactions instead.") + state: UserState! + historicalState: UserHistory! +} + +""" +Vault V2 position history +""" +type VaultV2PositionHistory { +""" +Vault shares history. +""" + shares( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Assets history, in underlying token. +""" + assets( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Assets history, in USD. +""" + assetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +} + +type VaultV2Position implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + user: User! + vault: VaultV2! +""" +Amount of vault shares +""" + shares: BigInt! +""" +Value of vault shares held, in underlying token units. +""" + assets: BigInt! +""" +Value of vault shares held, in USD. +""" + assetsUsd: Float +""" +Timeseries history for each of this position's stats. +""" + history: VaultV2PositionHistory! +""" +Profit & Loss of the position (due to interest and bad debt) since its inception, in loan assets. +""" + pnl: BigInt +""" +Profit & Loss of the position since its inception, quoted in USD using the asset's latest price. +""" + pnlUsd: Float +""" +Time-Weighted Average Return of the position since its inception (non-annualized). +""" + roe: Float +} + +type PaginatedVaultV2Positions { + items: [VaultV2Position!] + pageInfo: PageInfo +} + +""" +Vault V2 historical allocation data per cap +""" +type VaultV2HistoricalCaps { +""" +The cap this allocation refers to +""" + cap: VaultV2Caps +""" +Allocated assets in this cap, in vault asset units +""" + allocation: [BigIntDataPoint!]! +""" +Allocated assets in USD for display purpose +""" + allocationUsd: [FloatDataPoint!]! +""" +Absolute cap limit for this cap, in vault asset units +""" + absoluteCap: [BigIntDataPoint!]! +""" +Relative cap limit for this cap, in vault asset units +""" + relativeCap: [BigIntDataPoint!]! +""" +Relative allocation (allocation / totalAssets) +""" + relativeAllocation: [FloatDataPoint!]! +} + +type PaginatedVaultV2HistoricalCaps { + items: [VaultV2HistoricalCaps!] + pageInfo: PageInfo +} + +""" +Vault V2 history +""" +type VaultV2History { +""" +Total value of vault holdings, in underlying token units. +""" + totalAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Total value of vault holdings, in USD for display purpose. +""" + totalAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault shares total supply. +""" + totalSupply( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Real assets in the vault (excluding virtual accrual). +""" + realAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Real assets in USD for display purpose. +""" + realAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +The assets deposited to the vault that are not generating interest. +""" + idleAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Idle assets in USD for display purpose. +""" + idleAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Value of shares quoted in assets +""" + sharePrice( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Average APY computed from share price evolution over a lookback period (1-24 hours). Returns annualized compound rate. +""" + avgApy( + options: TimeseriesOptions +""" +Number of hours to look back for APY calculation (1-24) +""" + lookbackHours: Int + ): [FloatDataPoint!]! +""" +Average Net APY computed from share price evolution over a lookback period (1-24 hours). Returns annualized compound rate. Includes rewards and deductedfees. +""" + avgNetApy( + options: TimeseriesOptions +""" +Number of hours to look back for APY calculation (1-24) +""" + lookbackHours: Int + ): [FloatDataPoint!]! +""" +Historical allocation data grouped by caps. Returns allocation timeseries for requested caps within the requested timerange. +""" + caps( + options: TimeseriesOptions +""" +Filter allocations by cap types. Defaults to all cap types if not provided. +""" + capType_in: [VaultV2CapType!] + ): PaginatedVaultV2HistoricalCaps! +} + +type TimelockFailedCheckVaultV2WarningMetadata { + functionName: String! + currentTimelock: BigInt! + requiredTimelock: BigInt! +} + +type VaultV2ListingMetadataHistoryChange { + action: String! + timestamp: Float! +} + +type VaultV2Warning { + type: String! + level: VaultV2WarningLevel! + metadata: VaultV2WarningMetadata +} + +""" +Warning level for Vault V2 warnings. GREEN indicates passing checks, YELLOW indicates caution, RED indicates danger. +""" +enum VaultV2WarningLevel { + YELLOW + RED + GREEN +} + +union VaultV2WarningMetadata =UnrecognizedAssetVaultWarningMetadata | TimelockVaultV2WarningMetadata | NotWhitelistedVaultV2WarningMetadata | CustomMetadata + +type UnrecognizedAssetVaultWarningMetadata implements AssetReference{ +""" +The asset. +""" + asset: Asset! +} + +type TimelockVaultV2WarningMetadata { + failedChecks: [TimelockFailedCheckVaultV2WarningMetadata!]! +} + +type NotWhitelistedVaultV2WarningMetadata { + history: [VaultV2ListingMetadataHistoryChange!]! +} + +type CustomMetadata { + content: String +} + +""" +Vault V2 allocator +""" +type VaultV2Allocator { +""" +Allocator account. +""" + allocator: Account! +""" +Allocator since block number +""" + blockNumber: BigInt! +""" +Allocator since timestamp +""" + timestamp: BigInt! +} + +type VaultV2PendingConfig { +""" +Timestamp at which the pending config can be applied +""" + validAt: BigInt! + functionName: VaultV2TimelockedFunctionName! +""" +Raw timelocked function data +""" + data: HexString! + decodedData: VaultV2PendingConfigDecodedData! +""" +Transaction hash that submitted the pending action +""" + txHash: HexString! +} + +enum VaultV2TimelockedFunctionName { + SetIsAllocator + SetReceiveSharesGate + SetSendSharesGate + SetReceiveAssetsGate + SetSendAssetsGate + SetAdapterRegistry + AddAdapter + RemoveAdapter + IncreaseTimelock + DecreaseTimelock + SetPerformanceFee + SetManagementFee + SetPerformanceFeeRecipient + SetManagementFeeRecipient + IncreaseAbsoluteCap + IncreaseRelativeCap + SetForceDeallocatePenalty + Abdicate +} + +union VaultV2PendingConfigDecodedData =VaultV2SetIsAllocatorPendingData | VaultV2SetReceiveSharesGatePendingData | VaultV2SetSendSharesGatePendingData | VaultV2SetReceiveAssetsGatePendingData | VaultV2SetSendAssetsGatePendingData | VaultV2SetAdapterRegistryPendingData | VaultV2AdapterPendingData | VaultV2TimelockPendingData | VaultV2SetPerformanceFeePendingData | VaultV2SetManagementFeePendingData | VaultV2SetPerformanceFeeRecipientPendingData | VaultV2SetManagementFeeRecipientPendingData | VaultV2IncreaseCapPendingData | VaultV2SetForceDeallocatePenaltyPendingData | VaultV2AbdicatePendingData + +type VaultV2SetIsAllocatorPendingData { +""" +Pending allocator status +""" + isAllocator: Boolean! +""" +Allocator account. +""" + account: Account! +} + +type VaultV2SetReceiveSharesGatePendingData { +""" +Pending receive shares gate +""" + receiveSharesGate: Address! +} + +type VaultV2SetSendSharesGatePendingData { +""" +Pending send shares gate +""" + sendSharesGate: Address! +} + +type VaultV2SetReceiveAssetsGatePendingData { +""" +Pending receive assets gate +""" + receiveAssetsGate: Address! +} + +type VaultV2SetSendAssetsGatePendingData { +""" +Pending send assets gate +""" + sendAssetsGate: Address! +} + +type VaultV2SetAdapterRegistryPendingData { +""" +Pending adapter registry +""" + adapterRegistry: Address! +} + +type VaultV2AdapterPendingData implements ActiveAdapterData{ + adapterAddress: Address! +""" +The adapter. Null if the adapter is not recognized. +""" + adapter: VaultV2Adapter +} + +type VaultV2TimelockPendingData { +""" +Pending timelock duration +""" + timelock: BigInt! +""" +Function selector +""" + selector: HexString! +""" +Function name +""" + functionName: String! +} + +type VaultV2SetPerformanceFeePendingData { +""" +Pending performance fee +""" + performanceFee: BigInt! +} + +type VaultV2SetManagementFeePendingData { +""" +Pending management fee +""" + managementFee: BigInt! +} + +type VaultV2SetPerformanceFeeRecipientPendingData { +""" +Pending performance fee recipient +""" + performanceFeeRecipient: Address! +} + +type VaultV2SetManagementFeeRecipientPendingData { +""" +Management fee recipient +""" + managementFeeRecipient: Address! +} + +type VaultV2IncreaseCapPendingData { +""" +Pending absolute/relative cap +""" + cap: BigInt! + config: VaultV2CapConfig! +} + +type VaultV2SetForceDeallocatePenaltyPendingData implements ActiveAdapterData{ + adapterAddress: Address! +""" +The adapter. Null if the adapter is not recognized. +""" + adapter: VaultV2Adapter +""" +Pending force deallocate penalty +""" + forceDeallocatePenalty: BigInt! +} + +type VaultV2AbdicatePendingData { +""" +Function selector +""" + selector: HexString! +""" +Function name +""" + functionName: String! +} + +type PaginatedVaultV2PendingConfig { + items: [VaultV2PendingConfig!] + pageInfo: PageInfo +} + +""" +Vault V2 sentinel +""" +type VaultV2Sentinel { +""" +Sentinel account. +""" + sentinel: Account! +""" +Sentinel since block number +""" + blockNumber: BigInt! +""" +Sentinel since timestamp +""" + timestamp: BigInt! +} + +""" +Vault V2 allocator +""" +type VaultV2Timelock { +""" +Targeted selector +""" + selector: HexString! +""" +Targeted function +""" + functionName: String! +""" +Duration of the timelock +""" + duration: BigInt! +""" +Last updated at block number +""" + blockNumber: BigInt! +""" +Last updated at timestamp +""" + timestamp: BigInt! +""" +The timestamp the function was abdicated at, null if not abdicated +""" + abdicatedAt: BigInt +} + +type VaultV2 implements AssetReference & ChainReference{ +""" +The asset. +""" + asset: Asset! +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + name: String! + symbol: String! +""" +Curators operating on this vault +""" + curators( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedCurators! + curator: Account! + owner: Account! + creationBlockNumber: BigInt! + creationTimestamp: BigInt! + adapters( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedVaultV2Adapters! + liquidityAdapter: VaultV2Adapter +""" +Total assets deposited to the vault. At the moment, interest is not virtually accrued +""" + totalAssets: BigInt + totalSupply: BigInt! +""" +Total assets deposited to the vault. At the moment, interest is not virtually accrued +""" + totalAssetsUsd: Float +""" +The assets deposited to the vault that are not generating interest. +""" + idleAssets: BigInt! +""" +The USD value of assets deposited to the vault that are not generating interest. +""" + idleAssetsUsd: Float +""" +The liquidity available from the liquidity adapter + idle assets. +""" + liquidity: BigInt! +""" +The USD value of liquidity available from the liquidity adapter + idle assets. +""" + liquidityUsd: Float +""" +Value of shares quoted in assets +""" + sharePrice: Float! +""" +Rewards aggregated from all underlying adapters + vault specific campaigns. Each underlying rewards are weighted by the adapter's asset allocation. +""" + rewards: [VaultStateReward!]! + performanceFee: Float! + performanceFeeRecipient: Address! +""" +Annual management fee rate (unitless fraction, e.g., 0.025 for 2.5%) +""" + managementFee: Float! + managementFeeRecipient: Address! +""" +Max rate per second +""" + maxRate: BigInt! +""" +Max APY +""" + maxApy: Float! + allocators: [VaultV2Allocator!]! + caps( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedVaultV2Caps! + sentinels: [VaultV2Sentinel!]! + timelocks: [VaultV2Timelock!]! +""" +Historical state data of the vault +""" + historicalState: VaultV2History! + warnings( + where: VaultV2WarningsFilters + ): [VaultV2Warning!]! + positions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: VaultV2PositionFilters + ): PaginatedVaultV2Positions! + pendingConfigs( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int +""" +Filter pending config by function name. +""" + functionName_in: [VaultV2TimelockedFunctionName!] + ): PaginatedVaultV2PendingConfig! +""" +A VaultV2 is listed if the curator is listed and the vault passes our sanity checks. +""" + listed: Boolean! +""" +Curated listing history for this vault: the chronological sequence of `Added` and `Removed` transitions from morpho-blue-api-metadata. Empty if the vault was never listed. +""" + listingHistory: [VaultListingHistoryEvent!]! + factory: VaultV2Factory! + type: VaultV2Type +""" +Decoded liquidity data associated with the vault's liquidity adapter. +""" + liquidityData: VaultV2LiquidityData +""" +The free force-deallocatable liquidity (sum of direct liquidity from non-liquidity adapters with zero penalty). +""" + forceDeallocatableLiquidity: BigInt! +""" +The USD value of free force-deallocatable liquidity. +""" + forceDeallocatableLiquidityUsd: Float +""" +Realized average APY of the vault, calculated from share price evolution over a predefined lookback period. Uses normalized timestamps (rounded to hour/day/week boundaries) for optimal caching and performance. Available periods: 1h, 6h (default), 1d, 7d, 30d, 90d, 1y, or 'inception' for all-time APY. +""" + avgApy( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV2LookbackPeriod + ): Float @deprecated(reason: "Use avgNetApyExcludingRewards instead.") +""" +Realized average net APY of the vault (after fees, with rewards), calculated from share price evolution over a predefined lookback period. Uses normalized timestamps (rounded to hour/day/week boundaries) for optimal caching and performance. Available periods: 1h, 6h (default), 1d, 7d, 30d, 90d, 1y, or 'inception' for all-time net APY. +""" + avgNetApy( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV2LookbackPeriod + ): Float +""" +Realized average net APY of the vault after all fees (performance + management), excluding rewards. Derived from share price evolution over a predefined lookback period. +""" + avgNetApyExcludingRewards( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV2LookbackPeriod + ): Float +""" +Current APY of the vault (before fees), derived from liquidity adapter rates. +""" + apy: Float +""" +Current net APY of the vault (after fees, including rewards), derived from liquidity adapter rates. +""" + netApy: Float +""" +Instantaneous net APY of the vault after all fees (performance + management), excluding rewards. +""" + netApyExcludingRewards: Float +""" +Full gate configuration including abdication and pending state. +""" + gatesConfig: VaultV2GatesConfig +""" +Performance fee configuration with abdication and pending state. +""" + performanceFeeConfig: VaultV2SelectorValueConfig! +""" +Management fee configuration with abdication and pending state. +""" + managementFeeConfig: VaultV2SelectorValueConfig! +""" +Performance fee recipient configuration with abdication and pending state. +""" + performanceFeeRecipientConfig: VaultV2SelectorAddressConfig! +""" +Management fee recipient configuration with abdication and pending state. +""" + managementFeeRecipientConfig: VaultV2SelectorAddressConfig! + metadata: VaultV2Metadata +} + +""" +Filtering options for vault V2 warnings. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultV2WarningsFilters { +""" +Filtering options for vault V2 warnings. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [String!] +""" +Filtering options for vault V2 warnings. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + level_in: [VaultV2WarningLevel!] +} + +""" +Filtering options for Vault V2 positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultV2PositionFilters { +""" +Filtering options for Vault V2 positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +} + +""" +Type of VaultV2 +""" +enum VaultV2Type { + MorphoVault + FeeWrapper +} + +union VaultV2LiquidityData =MarketV1LiquidityData | MetaMorphoLiquidityData + +""" +Liquidity data for a MorphoMarketV1 adapter +""" +type MarketV1LiquidityData { +""" +The Morpho Blue market identified by this liquidity data. Null if the market is not recognized. +""" + market: Market +} + +""" +Liquidity data for a MetaMorpho (Vault V1) adapter +""" +type MetaMorphoLiquidityData { +""" +The MetaMorpho vault associated with this adapter. Null if the vault is not recognized. +""" + metaMorpho: Vault +} + +""" +Predefined lookback periods for vault APY calculations. Using these periods ensures better query performance through timestamp normalization and caching. +""" +enum VaultV2LookbackPeriod { +""" +1 hour lookback period +""" + ONE_HOUR +""" +6 hours lookback period (default) +""" + SIX_HOURS +""" +1 day (24 hours) lookback period +""" + ONE_DAY +""" +7 days (1 week) lookback period +""" + SEVEN_DAYS +""" +30 days (~1 month) lookback period +""" + THIRTY_DAYS +""" +90 days (~3 months) lookback period +""" + NINETY_DAYS +""" +1 year (365 days) lookback period +""" + ONE_YEAR +""" +Since vault inception (all-time) +""" + INCEPTION +} + +type MarketWarning { + type: String! + level: WarningLevel! + metadata: MarketWarningMetadata +} + +enum WarningLevel { + YELLOW + RED +} + +union MarketWarningMetadata =BadDebtRealizedMarketWarningMetadata | BadDebtUnrealizedMarketWarningMetadata | IncorrectOracleConfigurationMarketWarningMetadata | OraclePriceDerivationMarketWarningMetadata | UnrecognizedCollateralAssetMarketWarningMetadata | UnrecognizedLoanAssetMarketWarningMetadata | CustomMetadata + +type BadDebtRealizedMarketWarningMetadata { + badDebtUsd: Float + badDebtAssets: BigInt! + totalSupplyAssets: BigInt! + badDebtShare: Float! +} + +type BadDebtUnrealizedMarketWarningMetadata { + badDebtUsd: Float + badDebtAssets: BigInt! + totalSupplyAssets: BigInt! + badDebtShare: Float! +} + +type IncorrectOracleConfigurationMarketWarningMetadata { + type: String! + scaleFactor: BigInt + expectedScaleFactor: BigInt + expectedScaleFactorExponent: BigInt +} + +type OraclePriceDerivationMarketWarningMetadata { +""" +Oracle derivation warning subtype. +""" + type: String! +""" +Current on-chain oracle price, serialized as a bigint. +""" + onChainPrice: BigInt! +""" +Expected oracle price derived from USD reference prices. +""" + expectedPrice: BigInt! +""" +Ratio between on-chain and expected prices. +""" + deviationFactor: Float! +} + +type UnrecognizedCollateralAssetMarketWarningMetadata implements AssetReference{ +""" +The asset. +""" + asset: Asset! +} + +type UnrecognizedLoanAssetMarketWarningMetadata implements AssetReference{ +""" +The asset. +""" + asset: Asset! +} + +""" +Morpho Blue supply and borrow side concentrations +""" +type MarketConcentration { +""" +Borrowers Herfindahl-Hirschman Index +""" + supplyHhi: Float @deprecated(reason: "Deprecated.") +""" +Borrowers Herfindahl-Hirschman Index +""" + borrowHhi: Float @deprecated(reason: "Deprecated.") +} + +""" +Market APY aggregates +""" +type MarketApyAggregates { +""" +Average market supply APY excluding rewards +""" + supplyApy: Float +""" +Average market borrow APY excluding rewards +""" + borrowApy: Float +""" +Average market supply APY including rewards +""" + netSupplyApy: Float +""" +Average market borrow APY including rewards +""" + netBorrowApy: Float +} + +""" +IRM curve data point +""" +type IRMCurveDataPoint { +""" +Market utilization rate +""" + utilization: Float! +""" +Supply APY at utilization rate +""" + supplyApy: Float! +""" +Borrow APY at utilization rate +""" + borrowApy: Float! +} + +""" +Bad debt realized in the market +""" +type MarketBadDebt { +""" +Amount of bad debt realized in the market in underlying units. +""" + underlying: BigInt! +""" +Amount of bad debt realized in the market in USD. +""" + usd: Float +} + +""" +Market oracle information +""" +type MarketOracleInfo { + type: OracleType! +} + +""" +Market oracle feeds +""" +type MarketOracleFeed { + baseFeedOneAddress: Address! + baseFeedOneDescription: String + baseFeedOneVendor: String + baseFeedTwoAddress: Address! + baseFeedTwoDescription: String + baseFeedTwoVendor: String + baseVault: Address + baseVaultDescription: String + baseVaultVendor: String + baseVaultConversionSample: BigInt + quoteFeedOneAddress: Address! + quoteFeedOneDescription: String + quoteFeedOneVendor: String + quoteFeedTwoAddress: Address! + quoteFeedTwoDescription: String + quoteFeedTwoVendor: String + quoteVault: Address + quoteVaultDescription: String + quoteVaultVendor: String + quoteVaultConversionSample: BigInt + scaleFactor: BigInt +} + +""" +Morpho Blue market +""" +type Market implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! +""" +On-chain market ID +""" + marketId: MarketId! +""" +IRM contract address +""" + irmAddress: Address! +""" +Liquidation LTV +""" + lltv: BigInt! +""" +Block number at which the market was created +""" + creationBlockNumber: Int! +""" +Timestamp at which the market was created +""" + creationTimestamp: BigInt! + id: ID! @deprecated(reason: "Use marketId and chainId instead.") + targetBorrowUtilization: BigInt! @deprecated(reason: "Deprecated. This field always returns 90%.") + targetWithdrawUtilization: BigInt! @deprecated(reason: "Deprecated. This field always returns 90%.") +""" +State history +""" + historicalState: MarketHistory + listed: Boolean! + creatorAddress: Address @deprecated(reason: "Deprecated.") + collateralAsset: Asset + loanAsset: Asset! + morphoBlue: MorphoBlue! +""" +Current state +""" + state: MarketState + oracleInfo: MarketOracleInfo @deprecated(reason: "Use oracle entity instead.") + oracleFeed: MarketOracleFeed @deprecated(reason: "Use oracle entity instead.") + oracle: Oracle + oracleAddress: Address! @deprecated(reason: "Use oracle.address instead.") + concentration: MarketConcentration @deprecated(reason: "Deprecated.") +""" +Market bad debt values +""" + badDebt: MarketBadDebt +""" +Market realized bad debt values +""" + realizedBadDebt: MarketBadDebt + dailyApys: MarketApyAggregates @deprecated(reason: "Use market.state daily average APYs instead.") + monthlyApys: MarketApyAggregates @deprecated(reason: "Use market.state monthly average APYs instead.") +""" +Current IRM curve at different utilization thresholds for display purpose +""" + currentIrmCurve( + numberOfPoints: Int + ): [IRMCurveDataPoint!] +""" +Underlying amount of assets that can be reallocated to this market +""" + collateralPrice: BigInt @deprecated(reason: "Use state.price instead.") + reallocatableLiquidityAssets: BigInt! + warnings: [MarketWarning!]! +""" +Public allocator shared liquidity available reallocations +""" + publicAllocatorSharedLiquidity: [PublicAllocatorSharedLiquidity!] +""" +Whitelisted vaults having the market enabled with a non-zero cap. +""" + supplyingVaults: [Vault!]! +""" +Whitelisted vaults having the market enabled or still allocated. +""" + supplyingVaultV2s: [VaultV2!]! +""" +Pre-liquidation contracts deployed for this market with known default parameters +""" + preLiquidations( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedPreLiquidations! +} + +""" +66 character long hexadecimal market ID +""" +scalar MarketId + +type PaginatedMarkets { + items: [Market!] + pageInfo: PageInfo +} + +""" +Pre-liquidation contract deployed for a market +""" +type PreLiquidationModel { +""" +Pre-liquidation contract address +""" + address: Address! +""" +Pre-liquidation LTV threshold +""" + preLltv: BigInt! +""" +Pre-liquidation close factor parameter 1 +""" + preLCF1: BigInt! +""" +Pre-liquidation close factor parameter 2 +""" + preLCF2: BigInt! +""" +Pre-liquidation incentive factor parameter 1 +""" + preLIF1: BigInt! +""" +Pre-liquidation incentive factor parameter 2 +""" + preLIF2: BigInt! +""" +Oracle used for pre-liquidation price +""" + preLiquidationOracle: Address! +} + +type PaginatedPreLiquidations { + items: [PreLiquidationModel!] + pageInfo: PageInfo +} + +""" +Amount of collateral at risk of liquidation at collateralPriceRatio * oracle price +""" +type CollateralAtRiskDataPoint { + collateralPriceRatio: Float! + collateralAssets: BigInt! + collateralUsd: Float! +} + +""" +Market collateral at risk of liquidation +""" +type MarketCollateralAtRisk { +""" +Total collateral at risk of liquidation at certain prices thresholds. +""" + collateralAtRisk: [CollateralAtRiskDataPoint!] + market: Market! +} + +""" +Market oracle accuracy versus spot price +""" +type MarketOracleAccuracy { + market: Market! +""" +Average oracle/spot prices deviation +""" + averagePercentDifference: Float @deprecated(reason: "Deprecated.") +""" +Maximum oracle/spot prices deviation +""" + maxPercentDifference: Float @deprecated(reason: "Deprecated.") +} + +""" +Market position state history +""" +type MarketPositionHistory { +""" +Collateral history. +""" + collateral( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Collateral value history, in loan assets. +""" + collateralValue( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Collateral value history, in USD. +""" + collateralUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +""" +Supply assets history. +""" + supplyAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Supply assets history, in USD. +""" + supplyAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +""" +Supply shares history. +""" + supplyShares( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Borrow assets history. +""" + borrowAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Borrow assets history, in USD. +""" + borrowAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +""" +Borrow shares history. +""" + borrowShares( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Margin history, in loan assets. +""" + margin( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Margin history, in USD. +""" + marginUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +} + +""" +Market position state +""" +type MarketPositionState { + id: ID! +""" +The latest update timestamp. +""" + timestamp: BigInt! +""" +The latest collateral assets indexed for this position. +""" + collateral: BigInt! +""" +The latest collateral assets indexed for this position, in USD. +""" + collateralUsd: Float +""" +The latest supply assets indexed for this position. +""" + supplyAssets: BigInt +""" +The latest supply assets indexed for this position, in USD. +""" + supplyAssetsUsd: Float +""" +The latest supply shares indexed for this position. +""" + supplyShares: BigInt! +""" +The latest borrow assets indexed for this position. +""" + borrowAssets: BigInt +""" +The latest borrow assets indexed for this position, in USD. +""" + borrowAssetsUsd: Float +""" +The latest borrow shares indexed for this position. +""" + borrowShares: BigInt! +""" +Value of the collateral in loan asset units, as computed by the market oracle. +""" + collateralValue: BigInt +""" +Margin of the position (collateralValue - borrowAssets). +""" + margin: BigInt +""" +Profit & Loss of the position's borrow side (due to the loan interest) since its inception, in loan assets. +""" + borrowPnl: BigInt +""" +Profit & Loss of the position's borrow side since its inception, quoted in USD using the loan asset's latest price. +""" + borrowPnlUsd: Float +""" +Time-Weighted Average Return of the position's borrow side since its inception. +""" + borrowRoe: Float +""" +Margin of the position in USD (collateralUsd - borrowAssetsUsd). +""" + marginUsd: Float +} + +""" +Market transaction +""" +type MarketTransaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + txHash: HexString! + timestamp: BigInt! + blockNumber: BigInt! + txIndex: Int! + logIndex: Int! + type: MarketTransactionType! + data: MarketTransactionData! + market: Market! + user: User! +} + +enum MarketTransactionType { + Supply + Withdraw + Borrow + Repay + SupplyCollateral + WithdrawCollateral + Liquidation +} + +union MarketTransactionData =MarketTransactionTransferData | MarketTransactionCollateralTransferData | MarketTransactionLiquidationData + +""" +Market supply, withdraw, borrow, or repay data +""" +type MarketTransactionTransferData { + assets: BigInt! + shares: BigInt! +} + +""" +Market supply-collateral or withdraw-collateral data +""" +type MarketTransactionCollateralTransferData { + assets: BigInt! +} + +""" +Market liquidation data +""" +type MarketTransactionLiquidationData { + liquidator: String! + repaidAssets: BigInt! + repaidShares: BigInt! + seizedAssets: BigInt! + badDebtAssets: BigInt! + badDebtShares: BigInt! +} + +type PaginatedMarketTransactions { + items: [MarketTransaction!] + pageInfo: PageInfo +} + +type PaginatedMetaMorphoAdapterFactories { + items: [MetaMorphoAdapterFactory!] + pageInfo: PageInfo +} + +type PaginatedMorphoBlue { + items: [MorphoBlue!] + pageInfo: PageInfo +} + +type PaginatedOracles { + items: [Oracle!] + pageInfo: PageInfo +} + +type PaginatedOracleFeeds { + items: [OracleFeed!] + pageInfo: PageInfo +} + +type PaginatedOracleVaults { + items: [OracleVault!] + pageInfo: PageInfo +} + +""" +Public allocator flow caps +""" +type PublicAllocatorFlowCaps { +""" +Public allocator flow cap in USD +""" + maxIn: BigInt! +""" +Public allocator flow cap in underlying +""" + maxOut: BigInt! + market: Market! +} + +""" +Public allocator configuration +""" +type PublicAllocatorConfig { +""" +Fee charged per reallocation (in chain native asset) +""" + fee: BigInt! +""" +Accumulated fees not yet claimed (in chain native asset) +""" + accruedFee: BigInt! +""" +Total fees collected over time (in chain native asset) +""" + overallFee: BigInt! +""" +Address authorized to manage this public allocator config +""" + admin: Address! +""" +Flow caps defining max in/out amounts per market +""" + flowCaps: [PublicAllocatorFlowCaps!]! +} + +""" +Public allocator +""" +type PublicAllocator { + id: ID! + address: Address! + creationBlockNumber: Int! + morphoBlue: MorphoBlue! +} + +type PaginatedPublicAllocator { + items: [PublicAllocator!] + pageInfo: PageInfo +} + +""" +Public allocator reallocate +""" +type PublicAllocatorReallocate { + id: ID! + timestamp: BigInt! + hash: HexString! + logIndex: Int! + blockNumber: BigInt! + sender: Address! + assets: BigInt! + type: PublicAllocatorReallocateType! + market: Market! + vault: Vault! + publicAllocator: PublicAllocator! +} + +enum PublicAllocatorReallocateType { + Deposit + Withdraw +} + +type PaginatedPublicAllocatorReallocates { + items: [PublicAllocatorReallocate!] + pageInfo: PageInfo +} + +type PaginatedTransactions { + items: [Transaction!] + pageInfo: PageInfo +} + +type PaginatedUsers { + items: [User!] + pageInfo: PageInfo +} + +""" +Meta Morpho vault event data +""" +type VaultAdminEvent { + hash: HexString! + timestamp: BigInt! + type: String! + data: VaultAdminEventData +} + +union VaultAdminEventData =SetCuratorEventData | SetFeeEventData | SetFeeRecipientEventData | SetGuardianEventData | SetIsAllocatorEventData | SetSkimRecipientEventData | SetSupplyQueueEventData | SetWithdrawQueueEventData | SkimEventData | CapEventData | TimelockEventData | ReallocateSupplyEventData | ReallocateWithdrawEventData | OwnershipEventData | RevokeCapEventData | RevokePendingMarketRemovalEventData + +""" +SetCurator event data +""" +type SetCuratorEventData { + curatorAddress: Address! +} + +""" +SetFee event data +""" +type SetFeeEventData { + fee: BigInt! +} + +""" +SetFeeRecipient event data +""" +type SetFeeRecipientEventData { + feeRecipient: Address! +} + +""" +SetGuardian event data +""" +type SetGuardianEventData { + guardian: Address! +} + +""" +SetIsAllocator event data +""" +type SetIsAllocatorEventData { + allocator: Address! + isAllocator: Boolean! +} + +""" +SetSkimRecipient event data +""" +type SetSkimRecipientEventData { + skimRecipient: Address! +} + +""" +SetSupplyQueue event data +""" +type SetSupplyQueueEventData { + supplyQueue: [Market!]! +} + +""" +SetWithdrawQueue event data +""" +type SetWithdrawQueueEventData { + withdrawQueue: [Market!]! +} + +""" +Skim event data +""" +type SkimEventData implements AssetReference{ +""" +The asset. +""" + asset: Asset! + amount: BigInt! +} + +""" +Event data for cap-related operation +""" +type CapEventData { + market: Market! + cap: BigInt! +} + +""" +Event data for timelock-related operation +""" +type TimelockEventData { + timelock: BigInt! +} + +""" +ReallocateSupply event data +""" +type ReallocateSupplyEventData { + market: Market! + suppliedAssets: BigInt! + suppliedShares: BigInt! +} + +""" +ReallocateWithdraw event data +""" +type ReallocateWithdrawEventData { + market: Market! + withdrawnAssets: BigInt! + withdrawnShares: BigInt! +} + +""" +Event data for ownership-related operations +""" +type OwnershipEventData { + owner: Address! +} + +""" +Event data for revokeCap operation +""" +type RevokeCapEventData { + market: Market! +} + +""" +Event data for revokePendingMarketRemoval operation +""" +type RevokePendingMarketRemovalEventData { + market: Market! +} + +type PaginatedVaultAdminEvent { + items: [VaultAdminEvent!] + pageInfo: PageInfo +} + +""" +MetaMorpho Vault Factories +""" +type VaultFactory implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: Int! +} + +""" +A single transition in the curated listing history of a vault (sourced from morpho-blue-api-metadata). +""" +type VaultListingHistoryEvent { + action: VaultListingAction! +""" +Unix timestamp (seconds) of the action. +""" + timestamp: Float! +} + +""" +Action type for a vault listing transition: `Added` when the vault was added to the curated listing, `Removed` when it was delisted. +""" +enum VaultListingAction { + Added + Removed +} + +""" +MetaMorpho vault state +""" +type VaultState { +""" +Block number of the state +""" + blockNumber: BigInt! +""" +Total value of vault holdings, in underlying token units. +""" + totalAssets: BigInt! +""" +Total value of vault holdings, in USD for display purpose. +""" + totalAssetsUsd: Float +""" +Vault shares total supply. +""" + totalSupply: BigInt! +""" +Vault APY excluding rewards, before deducting the performance fee. +""" + apy: Float! +""" +Vault APY including rewards and underlying yield, after deducting the performance fee. +""" + netApy: Float! +""" +Last update timestamp. +""" + timestamp: BigInt! +""" +Block information +""" + block: Block! +""" +Vault allocation on Morpho Blue markets. +""" + allocation: [VaultAllocation!]! +""" +Vault state ID +""" + id: ID! @deprecated(reason: "Use Vault.address and Vault.chainId instead.") +""" +Value of shares quoted in assets +""" + sharePriceNumber: Float +""" +Value of WAD shares in USD +""" + sharePriceUsd: Float +""" +Vault performance fee. +""" + fee: Float! +""" +Stores the total assets managed by this vault when the fee was last accrued, in underlying token units. +""" + lastTotalAssets: BigInt! @deprecated(reason: "Use totalAssets instead.") +""" +Vault curator address. +""" + curator: Address! +""" +Fee recipient address. +""" + feeRecipient: Address! +""" +Guardian address. +""" + guardian: Address! +""" +Owner address. +""" + owner: Address! +""" +Skim recipient address. +""" + skimRecipient: Address! +""" +Timelock in seconds. +""" + timelock: BigInt! +""" +Pending owner address. +""" + pendingOwner: Address +""" +Deprecated direct-only vault state rewards. +""" + rewards: [VaultStateReward!]! @deprecated(reason: "Use allRewards instead to include forwarded rewards.") +""" +Vault state rewards including forwarded rewards when the new Vault V1 rewards clients are enabled. +""" + allRewards: [VaultStateReward!]! +""" +Vault APY excluding rewards, after deducting the performance fee. +""" + netApyWithoutRewards: Float! @deprecated(reason: "Use netApyExcludingRewards instead.") +""" +Instantaneous vault APY excluding rewards, after deducting the performance fee. +""" + netApyExcludingRewards: Float! +""" +Realized average net APY of the vault after performance fee, excluding rewards. Derived from share price evolution over a predefined lookback period. +""" + avgNetApyExcludingRewards( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV1LookbackPeriod + ): Float +""" +Curators operating on this vault +""" + curators: [Curator!]! +""" +Additional information about the curator address. +""" + curatorMetadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +""" +Additional information about the owner address. +""" + ownerMetadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +""" +Additional information about the guardian address. +""" + guardianMetadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +""" +Pending config +""" + pendingConfigs( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int +""" +Filter pending config by function name. +""" + functionName_in: [VaultTimelockedFunctionName!] + ): PaginatedVaultPendingConfig! +""" +Average vault APY including rewards, after deducting the performance fee. Supports parameterized lookback periods (default: 6h). +""" + avgNetApy( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV1LookbackPeriod + ): Float +""" +Daily Vault APY excluding rewards, before deducting the performance fee. +""" + dailyApy: Float @deprecated(reason: "Use avgNetApyExcludingRewards(lookback: ONE_DAY) instead.") +""" +Daily Vault APY including rewards, after deducting the performance fee. +""" + dailyNetApy: Float @deprecated(reason: "Use avgNetApy with lookback parameter instead.") +""" +Weekly Vault APY excluding rewards, before deducting the performance fee. +""" + weeklyApy: Float @deprecated(reason: "Use avgNetApyExcludingRewards(lookback: SEVEN_DAYS) instead.") +""" +Weekly Vault APY including rewards, after deducting the performance fee. +""" + weeklyNetApy: Float @deprecated(reason: "Use avgNetApy with lookback parameter instead.") +} + +""" +Predefined lookback periods for V1 vault APY calculations. Using these periods ensures better query performance through timestamp normalization and caching. +""" +enum VaultV1LookbackPeriod { +""" +1 hour lookback period +""" + ONE_HOUR +""" +6 hours lookback period (default) +""" + SIX_HOURS +""" +1 day (24 hours) lookback period +""" + ONE_DAY +""" +7 days (1 week) lookback period +""" + SEVEN_DAYS +""" +30 days (~1 month) lookback period +""" + THIRTY_DAYS +""" +90 days (~3 months) lookback period +""" + NINETY_DAYS +""" +1 year (365 days) lookback period +""" + ONE_YEAR +""" +Since vault inception (all-time) +""" + INCEPTION +} + +enum VaultTimelockedFunctionName { + SetCap + SetTimelock + SetGuardian + RemoveMarket +} + +""" +Meta-Morpho vault history +""" +type VaultHistory { +""" +Total value of vault holdings, in underlying token units. +""" + totalAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Vault shares total supply. +""" + totalSupply( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Total value of vault holdings, in USD for display purpose. +""" + totalAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault APY excluding rewards, before deducting the performance fee. +""" + apy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault APY excluding rewards, after deducting the performance fee. +""" + netApyWithoutRewards( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault APY including rewards, after deducting the performance fee. +""" + netApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault performance fee. +""" + fee( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault allocation on Morpho Blue markets. +""" + allocation: [VaultAllocationHistory!]! +""" +Value of shares quoted in assets +""" + sharePriceNumber( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Value of WAD shares in USD +""" + sharePriceUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Vault APY excluding rewards, before deducting the performance fee. +""" + dailyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Vault APY including rewards, after deducting the performance fee. +""" + dailyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Vault APY excluding rewards, before deducting the performance fee. +""" + weeklyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Vault APY including rewards, after deducting the performance fee. +""" + weeklyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Vault APY excluding rewards, before deducting the performance fee. +""" + monthlyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Vault APY including rewards, after deducting the performance fee. +""" + monthlyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Vault APY excluding rewards, before deducting the performance fee. +""" + quarterlyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Vault APY including rewards, after deducting the performance fee. +""" + quarterlyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Vault APY excluding rewards, before deducting the performance fee. +""" + yearlyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Vault APY including rewards, after deducting the performance fee. +""" + yearlyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +All Time Vault APY excluding rewards, before deducting the performance fee. +""" + allTimeApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +All Time Vault APY including rewards, after deducting the performance fee. +""" + allTimeNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +} + +type VaultListingMetadataHistoryChange { + action: String! + timestamp: Float! +} + +type VaultWarning { + type: String! + level: WarningLevel! + metadata: VaultWarningMetadata +} + +union VaultWarningMetadata =InvalidNameVaultWarningMetadata | InvalidSymbolVaultWarningMetadata | ShortTimelockVaultWarningMetadata | UnrecognizedDepositAssetVaultWarningMetadata | NotWhitelistedVaultWarningMetadata | CustomMetadata + +type InvalidNameVaultWarningMetadata { + reason: String! +} + +type InvalidSymbolVaultWarningMetadata { + reason: String! +} + +type ShortTimelockVaultWarningMetadata { + timelock: BigInt! +} + +type UnrecognizedDepositAssetVaultWarningMetadata implements AssetReference{ +""" +The asset. +""" + asset: Asset! +} + +type NotWhitelistedVaultWarningMetadata { + history: [VaultListingMetadataHistoryChange!]! +} + +type PaginatedMetaMorphos { + items: [Vault!] + pageInfo: PageInfo +} + +""" +MetaMorpho vault allocation +""" +type VaultAllocation { +""" +Block number in which the allocation was computed +""" + blockNumber: BigInt! +""" +Amount of asset supplied on market, in market underlying token units +""" + supplyAssets: BigInt! +""" +Amount of asset supplied on market, in USD for display purpose. +""" + supplyAssetsUsd: Float +""" +Amount of supplied shares on market. +""" + supplyShares: BigInt! +""" +Maximum amount of asset that can be supplied on market by the vault, in market underlying token units +""" + supplyCap: BigInt! +""" +Maximum amount of asset that can be supplied on market by the vault, in USD for display purpose. +""" + supplyCapUsd: Float +""" +Supply queue index +""" + supplyQueueIndex: Int +""" +Withdraw queue index +""" + withdrawQueueIndex: Int + id: ID! +""" +Pending maximum amount of asset that can be supplied on market by the vault, in market underlying token units +""" + pendingSupplyCap: BigInt +""" +Pending supply cap apply timestamp +""" + pendingSupplyCapValidAt: BigInt +""" +Pending maximum amount of asset that can be supplied on market by the vault, in USD for display purpose. +""" + pendingSupplyCapUsd: Float + removableAt: BigInt +""" +Whether realtime allocation is enabled for this market +""" + enabled: Boolean! @deprecated(reason: "Deprecated.") +""" +Block information +""" + block: Block + market: Market! +} + +""" +MetaMorpho vault allocation history +""" +type VaultAllocationHistory { + market: Market! +""" +Amount of asset supplied on market, in market underlying token units +""" + supplyAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount of asset supplied on market, in USD for display purpose. +""" + supplyAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Maximum amount of asset that can be supplied on market by the vault, in market underlying token units +""" + supplyCap( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Maximum amount of asset that can be supplied on market by the vault, in USD for display purpose. +""" + supplyCapUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +} + +type PaginatedMetaMorphoFactories { + items: [VaultFactory!] + pageInfo: PageInfo +} + +""" +Vault position state history +""" +type VaultPositionHistory { +""" +Vault shares history. +""" + shares( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Assets history, in underlying token. +""" + assets( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Assets history, in USD. +""" + assetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +} + +""" +Vault position state +""" +type VaultPositionState { + id: ID! +""" +The latest update timestamp. +""" + timestamp: BigInt! +""" +The latest supply assets indexed for this position. +""" + assets: BigInt +""" +The latest supply assets indexed for this position, in USD. +""" + assetsUsd: Float +""" +The latest supply shares indexed for this position. +""" + shares: BigInt! +""" +Profit & Loss of the position (due to interest and bad debt) since its inception, in loan assets. +""" + pnl: BigInt +""" +Profit & Loss of the position since its inception, quoted in USD using the asset's latest price. +""" + pnlUsd: Float +""" +Time-Weighted Average Return of the position since its inception (non-annualized). +""" + roe: Float +} + +type PaginatedMetaMorphoPositions { + items: [VaultPosition!] + pageInfo: PageInfo +} + +""" +Vault reallocate +""" +type VaultReallocate { + id: ID! + timestamp: BigInt! + hash: HexString! + logIndex: Int! + blockNumber: BigInt! + caller: Address! + shares: BigInt! + assets: BigInt! + type: VaultReallocateType! + market: Market! + vault: Vault! +} + +enum VaultReallocateType { + ReallocateSupply + ReallocateWithdraw +} + +type PaginatedVaultReallocates { + items: [VaultReallocate!] + pageInfo: PageInfo +} + +""" +MetaMorpho vault pending config +""" +type VaultPendingConfig { +""" +Timestamp at which the pending config can be applied +""" + validAt: BigInt! + functionName: VaultTimelockedFunctionName! + decodedData: VaultPendingConfigDecodedData! +""" +Transaction hash that submitted the pending action +""" + txHash: HexString! +} + +union VaultPendingConfigDecodedData =VaultSetCapPendingData | VaultSetTimelockPendingData | VaultSetGuardianPendingData | VaultRemoveMarketPendingData + +""" +Vault pending cap +""" +type VaultSetCapPendingData { +""" +Pending supply cap +""" + supplyCap: BigInt! + market: Market +} + +""" +Vault pending timelock +""" +type VaultSetTimelockPendingData { +""" +Pending timelock duration +""" + timelock: BigInt! +} + +""" +Vault pending guardian +""" +type VaultSetGuardianPendingData { +""" +Pending guardian +""" + guardian: Account! +} + +""" +Vault pending market removal +""" +type VaultRemoveMarketPendingData { + market: Market + caller: Account! +} + +type PaginatedVaultPendingConfig { + items: [VaultPendingConfig!] + pageInfo: PageInfo +} + +""" +Vault V1 transaction +""" +type VaultV1Transaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + txHash: HexString! + timestamp: BigInt! + blockNumber: BigInt! + shares: BigInt! + txIndex: Int! + logIndex: Int! + type: VaultV1TransactionType! + data: VaultV1TransactionData! +""" +Underlying assets amount. For transfers this is an approximation derived from the hour bucket share price or the latest current-hour state, and may be null when no valuation state is available. +""" + assets: BigInt + vault: Vault! +} + +enum VaultV1TransactionType { + Transfer + Deposit + Withdraw +} + +union VaultV1TransactionData =VaultV1DepositData | VaultV1WithdrawData | VaultV1TransferData + +""" +Vault V1 deposit data +""" +type VaultV1DepositData { + assets: BigInt! + sender: String! + onBehalf: String! +} + +""" +Vault V1 withdraw data +""" +type VaultV1WithdrawData { + assets: BigInt! + sender: String! + receiver: String! + onBehalf: String! +} + +""" +Vault V1 transfer data +""" +type VaultV1TransferData { + from: String! + to: String! +} + +""" +Cursor anchor for Vault V1 transactions. +""" +type VaultV1TransactionCursor { +""" +Transaction hash of the cursor anchor. +""" + txHash: HexString! +""" +Log index of the cursor anchor. +""" + logIndex: Int! +} + +""" +Page info for Vault V1 transactions cursor pagination. +""" +type VaultV1TransactionsPageInfo { +""" +Whether more items exist after this page. +""" + hasNextPage: Boolean! +""" +Cursor anchor of the last item in this page. Pass to `where.cursor` to fetch the next page when `hasNextPage` is true. Null only when this page is empty. +""" + endCursor: VaultV1TransactionCursor +""" +Number of items returned in this page (== items.length). +""" + count: Int! +} + +type PaginatedVaultV1Transactions { + items: [VaultV1Transaction!] + pageInfo: VaultV1TransactionsPageInfo +} + +type VaultV2Factory implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: BigInt! +} + +type PaginatedVaultV2s { + items: [VaultV2!] + pageInfo: PageInfo +} + +""" +Configuration for an address-based vault selector, including abdication and pending state +""" +type VaultV2SelectorAddressConfig { + address: Address + abdicated: Boolean! + pendingAbdicationExecutableAt: BigInt + pendingAddress: Address + pendingExecutableAt: BigInt +} + +""" +Configuration for a value-based vault selector, including abdication and pending state +""" +type VaultV2SelectorValueConfig { + value: BigInt + abdicated: Boolean! + pendingAbdicationExecutableAt: BigInt + pendingValue: BigInt + pendingExecutableAt: BigInt +} + +""" +Full gate configuration for vault V2 operations +""" +type VaultV2GatesConfig { + sendSharesGate: VaultV2SelectorAddressConfig! + receiveAssetsGate: VaultV2SelectorAddressConfig! + receiveSharesGate: VaultV2SelectorAddressConfig! + sendAssetsGate: VaultV2SelectorAddressConfig! +} + +""" +Vault V2 metadata +""" +type VaultV2Metadata { + description: String + image: String + forumLink: String @deprecated(reason: "Deprecated and always returns null.") +} + +""" +Vault V2 allocation event emitted when the vault allocates assets to (Allocate) or withdraws assets from (Deallocate) one of its adapters. +""" +type VaultV2AllocationTransaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + txHash: HexString! + logIndex: Int! + txIndex: Int! + blockNumber: BigInt! + timestamp: BigInt! + type: VaultV2AllocationEventType! +""" +Adapter receiving (Allocate) or returning (Deallocate) assets. +""" + adapter: String! +""" +Address that triggered the allocation change. +""" + sender: String! +""" +Amount of underlying assets moved between the vault and the adapter. +""" + assets: BigInt! +""" +Signed change in the vault's allocation to this adapter after the event. +""" + change: BigInt! +""" +Adapter-defined bytes32 identifiers describing the routed allocation. +""" + ids: [HexString!]! + vault: VaultV2! +} + +""" +Allocate or Deallocate event emitted by a vault V2 adapter. +""" +enum VaultV2AllocationEventType { + Allocate + Deallocate +} + +type PaginatedVaultV2AllocationTransactions { + items: [VaultV2AllocationTransaction!] + pageInfo: PageInfo +} + +type PaginatedVaultV2Factories { + items: [VaultV2Factory!] + pageInfo: PageInfo +} + +""" +Vault V2 transaction +""" +type VaultV2Transaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + txHash: HexString! + timestamp: BigInt! + blockNumber: BigInt! + shares: BigInt! + txIndex: Int! + logIndex: Int! + type: VaultV2TransactionType! + data: VaultV2TransactionData! +""" +Underlying assets amount. For transfers this is an approximation derived from the hour bucket share price or the latest current-hour state, and may be null when no valuation state is available. +""" + assets: BigInt + vault: VaultV2! +} + +enum VaultV2TransactionType { + Deposit + Withdraw + Transfer +} + +union VaultV2TransactionData =VaultV2DepositData | VaultV2WithdrawData | VaultV2TransferData + +""" +Vault V2 deposit data +""" +type VaultV2DepositData { + assets: BigInt! + sender: String! + onBehalf: String! +} + +""" +Vault V2 withdraw data +""" +type VaultV2WithdrawData { + assets: BigInt! + sender: String! + receiver: String! + onBehalf: String! +} + +""" +Vault V2 transfer data +""" +type VaultV2TransferData { + from: String! + to: String! +} + +type PaginatedVaultV2Transactions { + items: [VaultV2Transaction!] + pageInfo: PageInfo +} + +type Query { + chain( + id: Int! + ): Chain! + chains: [Chain!]! + assetByAddress( + address: String! + chainId: Int + ): Asset! + assets( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: AssetsFilters + orderBy: AssetOrderBy + orderDirection: OrderDirection + ): PaginatedAssets! + transactions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: TransactionsOrderBy + orderDirection: OrderDirection + where: TransactionFilters + ): PaginatedTransactions! @deprecated(reason: "Use vaultV1Transactions or marketTransactions instead.") + userByAddress( + address: String! + chainId: Int + ): User! + users( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: UsersOrderBy + orderDirection: OrderDirection + where: UsersFilters + ): PaginatedUsers! @deprecated(reason: "Use userByAddress or address-scoped queries instead.") + marketCollateralAtRisk( + uniqueKey: String! + chainId: Int + numberOfPoints: Int + ): MarketCollateralAtRisk! + marketById( + marketId: String! + chainId: Int! + ): Market! + markets( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: MarketOrderBy + orderDirection: OrderDirection + where: MarketFilters + ): PaginatedMarkets! + curator( + id: String! + ): Curator! + curators( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: CuratorFilters + ): PaginatedCurators! + marketOracleAccuracy( + marketId: String! + options: TimeseriesOptions + ): MarketOracleAccuracy! @deprecated(reason: "Use marketById instead.") + morphoBlueByAddress( + address: String! + chainId: Int + ): MorphoBlue! + morphoBlues( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: MorphoBlueOrderBy + orderDirection: OrderDirection + where: MorphoBlueFilters + ): PaginatedMorphoBlue! + marketPosition( + userAddress: String! + marketUniqueKey: String! + chainId: Int + ): MarketPosition! + marketPositions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: MarketPositionOrderBy + orderDirection: OrderDirection + where: MarketPositionFilters + ): PaginatedMarketPositions! + oracleFeedByAddress( + address: String! + chainId: Int + ): OracleFeed! + oracleFeeds( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: OracleFeedsFilters + ): PaginatedOracleFeeds! + oracleVaultByAddress( + address: String! + chainId: Int + ): OracleVault! + oracleVaults( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: OracleVaultsFilters + ): PaginatedOracleVaults! + oracleByAddress( + address: String! + chainId: Int + ): Oracle! + oracles( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: OraclesFilters + ): PaginatedOracles! + publicAllocator( + address: String! + chainId: Int + ): PublicAllocator! + publicAllocators( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: PublicAllocatorOrderBy + orderDirection: OrderDirection + where: PublicAllocatorFilters + ): PaginatedPublicAllocator! + publicAllocatorReallocates( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: PublicAllocatorReallocateOrderBy + orderDirection: OrderDirection + where: PublicallocatorReallocateFilters + ): PaginatedPublicAllocatorReallocates! + vaultFactoryByAddress( + address: String! + chainId: Int + ): VaultFactory! + vaultFactories: PaginatedMetaMorphoFactories! + vaultByAddress( + address: String! + chainId: Int + ): Vault! + vaults( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: VaultOrderBy + orderDirection: OrderDirection + where: VaultFilters + ): PaginatedMetaMorphos! + vaultPosition( + userAddress: String! + vaultAddress: String! + chainId: Int + ): VaultPosition! + vaultPositions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: VaultPositionOrderBy + orderDirection: OrderDirection + where: VaultPositionFilters + ): PaginatedMetaMorphoPositions! + vaultReallocates( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: VaultReallocateOrderBy + orderDirection: OrderDirection + where: VaultReallocateFilters + ): PaginatedVaultReallocates! + vaultV1Transactions( +""" +Number of items requested. Must be at least 1. +""" + first: Int + orderBy: VaultV1TransactionOrderBy + orderDirection: OrderDirection + where: VaultV1TransactionFilters + ): PaginatedVaultV1Transactions! + marketTransactions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped. Maximum 10000. +""" + skip: Int + orderBy: MarketTransactionOrderBy + orderDirection: OrderDirection + where: MarketTransactionFilters + ): PaginatedMarketTransactions! + vaultV2Factories: PaginatedVaultV2Factories! + vaultV2MetaMorphoAdapterFactories: PaginatedMetaMorphoAdapterFactories! + vaultV2s( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: VaultV2sFilters + orderBy: VaultV2OrderBy + orderDirection: OrderDirection + ): PaginatedVaultV2s! + vaultV2ByAddress( + address: String! + chainId: Int! + ): VaultV2! + vaultV2AllocationTransactions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int +""" +Chain ID of the vault V2. +""" + chainId: Int! +""" +Address of the vault V2. +""" + vaultAddress: String! + orderBy: VaultV2AllocationEventOrderBy + orderDirection: OrderDirection + where: VaultV2AllocationTransactionFilters + ): PaginatedVaultV2AllocationTransactions! + vaultV2PositionByAddress( + userAddress: String! + vaultAddress: String! + chainId: Int! + ): VaultV2Position! + vaultV2transactions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: VaultV2TransactionOrderBy + orderDirection: OrderDirection + where: VaultV2TransactionFilters + ): PaginatedVaultV2Transactions! +} + +input AssetsFilters { + search: String + symbol_in: [String!] + address_in: [String!] + chainId_in: [Int!] + tags_in: [String!] + listed: Boolean + isVaultAsset: Boolean + isCollateralAsset: Boolean + isLoanAsset: Boolean + isMarketAsset: Boolean + curator_in: [String!] +} + +enum AssetOrderBy { + Address + CredoraRiskScore @deprecated(reason: "Deprecated.") +} + +enum OrderDirection { + Asc + Desc +} + +enum TransactionsOrderBy { + Timestamp + Shares + Assets + RepaidShares + RepaidAssets + SeizedAssets + BadDebtShares + BadDebtAssets +} + +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input TransactionFilters { +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assetAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [TransactionType!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + hash: String +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + repaidAssets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + repaidAssets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + repaidShares_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + repaidShares_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + seizedAssets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + seizedAssets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + badDebtShares_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + badDebtShares_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + badDebtAssets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + badDebtAssets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + liquidator_in: [String!] +} + +enum UsersOrderBy { + Address +} + +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input UsersFilters { +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + address_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assetSymbol_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assetAddress_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +} + +enum MarketOrderBy { + UniqueKey + Lltv + BorrowAssets + BorrowAssetsUsd + SupplyAssets + SupplyAssetsUsd + BorrowShares + SupplyShares + Utilization + RateAtUTarget @deprecated(reason: "Use ApyAtTarget instead.") + ApyAtTarget + SupplyApy + NetSupplyApy + BorrowApy + NetBorrowApy + Fee + LoanAssetSymbol + CollateralAssetSymbol + TotalLiquidityUsd + AvgBorrowApy + AvgNetBorrowApy + DailyBorrowApy + DailyNetBorrowApy + CredoraRiskScore @deprecated(reason: "Deprecated.") + SizeUsd +} + +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input MarketFilters { +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + selector_in: [MarketSelectorInput!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + listed: Boolean +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + countryCode: String +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + isIdle: Boolean +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + uniqueKey_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + loanAssetTags_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateralAssetTags_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + oracleAddress_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + irmAddress_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateralAssetAddress_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateralAssetSelector_in: [AssetSelectorInput!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + loanAssetAddress_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + loanAssetSelector_in: [AssetSelectorInput!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + lltv_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + lltv_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowAssets_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowAssets_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowAssetsUsd_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowAssetsUsd_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyAssets_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyAssets_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyAssetsUsd_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyAssetsUsd_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowShares_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowShares_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyShares_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyShares_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + utilization_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + utilization_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + apyAtTarget_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + apyAtTarget_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyApy_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyApy_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + netSupplyApy_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + netSupplyApy_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowApy_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowApy_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + netBorrowApy_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + netBorrowApy_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + fee_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + fee_lte: Float +} + +""" +Selector for a market by chain ID and market ID (unique key) +""" +input MarketSelectorInput { +""" +Selector for a market by chain ID and market ID (unique key) +""" + chainId: Int! +""" +Selector for a market by chain ID and market ID (unique key) +""" + marketId: MarketId! +} + +""" +Selector for an asset by chain ID and address +""" +input AssetSelectorInput { +""" +Selector for an asset by chain ID and address +""" + chainId: Int! +""" +Selector for an asset by chain ID and address +""" + address: Address! +} + +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input CuratorFilters { +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + address_in: [String!] +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + verified: Boolean +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + ownerOnly: Boolean +} + +enum MorphoBlueOrderBy { + Address +} + +""" +Filtering options for morpho blue deployments. +""" +input MorphoBlueFilters { +""" +Filtering options for morpho blue deployments. +""" + address_in: [String!] +""" +Filtering options for morpho blue deployments. +""" + chainId_in: [Int!] +} + +enum MarketPositionOrderBy { + SupplyShares + BorrowShares + Collateral + HealthFactor +} + +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input MarketPositionFilters { +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketListed: Boolean +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + healthFactor_gte: Float +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + healthFactor_lte: Float +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyShares_gte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyShares_lte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowShares_gte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowShares_lte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateral_gte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateral_lte: BigInt +} + +input OracleFeedsFilters { + address_in: [String!] + chainId_in: [Int!] +} + +input OracleVaultsFilters { + address_in: [String!] + chainId_in: [Int!] +} + +input OraclesFilters { + address_in: [String!] + chainId_in: [Int!] +} + +enum PublicAllocatorOrderBy { + Address +} + +""" +Filtering options for public allocators. +""" +input PublicAllocatorFilters { +""" +Filtering options for public allocators. +""" + address_in: [String!] +""" +Filtering options for public allocators. +""" + chainId_in: [Int!] +} + +enum PublicAllocatorReallocateOrderBy { + Timestamp + Assets +} + +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input PublicallocatorReallocateFilters { +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultSelector_in: [VaultSelectorInput!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketSelector_in: [MarketSelectorInput!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [PublicAllocatorReallocateType!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +} + +""" +Selector for a vault by chain ID and address +""" +input VaultSelectorInput { +""" +Selector for a vault by chain ID and address +""" + chainId: Int! +""" +Selector for a vault by chain ID and address +""" + vaultAddress: String! +} + +enum VaultOrderBy { + Address + TotalAssets + TotalAssetsUsd + TotalSupply + Fee + Apy + NetApy + Name + Curator + AvgApy + AvgNetApy + DailyApy + DailyNetApy + CredoraRiskScore @deprecated(reason: "Deprecated.") +} + +input VaultFilters { + search: String + listed: Boolean + featured: Boolean + countryCode: String + address_in: [String!] + ownerAddress_in: [String!] + address_not_in: [String!] + creatorAddress_in: [String!] + factoryAddress_in: [String!] + curatorAddress_in: [String!] + symbol_in: [String!] + chainId_in: [Int!] + assetAddress_in: [String!] + assetSymbol_in: [String!] + assetTags_in: [String!] + marketUniqueKey_in: [String!] + apy_gte: Float + apy_lte: Float + netApy_gte: Float + netApy_lte: Float + fee_gte: Float + fee_lte: Float + totalAssets_gte: BigInt + totalAssets_lte: BigInt + totalAssetsUsd_gte: Float + totalAssetsUsd_lte: Float + totalSupply_gte: BigInt + totalSupply_lte: BigInt + publicAllocatorFee_lte: Float + publicAllocatorFeeUsd_lte: Float +} + +enum VaultPositionOrderBy { + Shares +} + +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultPositionFilters { +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultListed: Boolean +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +} + +enum VaultReallocateOrderBy { + Timestamp + Shares + Assets +} + +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultReallocateFilters { +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [VaultReallocateType!] +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +} + +enum VaultV1TransactionOrderBy { + Time + Shares +} + +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultV1TransactionFilters { +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [VaultV1TransactionType!] +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + hash: HexString +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + cursor: VaultV1TransactionCursorInput +} + +""" +Cursor anchor for Vault V1 transactions. +""" +input VaultV1TransactionCursorInput { +""" +Cursor anchor for Vault V1 transactions. +""" + txHash: HexString! +""" +Cursor anchor for Vault V1 transactions. +""" + logIndex: Int! +} + +enum MarketTransactionOrderBy { + Timestamp + Assets + Shares + RepaidAssets + RepaidShares + SeizedAssets + BadDebtAssets + BadDebtShares +} + +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" +input MarketTransactionFilters { +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + assetAddress_in: [String!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + type_in: [MarketTransactionType!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + hash: String +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + cursor: MarketTransactionCursorInput +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + timestamp_gte: Int +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + timestamp_lte: Int +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + assets_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + assets_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + shares_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + shares_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + repaidAssets_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + repaidAssets_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + seizedAssets_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + seizedAssets_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + badDebtAssets_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + badDebtAssets_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + liquidatorAddress_in: [String!] +} + +""" +Cursor anchor for exact market transaction pagination. +""" +input MarketTransactionCursorInput { +""" +Cursor anchor for exact market transaction pagination. +""" + txHash: HexString! +""" +Cursor anchor for exact market transaction pagination. +""" + logIndex: Int! +} + +input VaultV2sFilters { + chainId_in: [Int!] + address_in: [String!] + listed: Boolean + type_in: [VaultV2Type!] + curatorAddress_in: [Address!] + assetAddress_in: [Address!] + ownerAddress_in: [Address!] + performanceFee_gte: BigInt + performanceFee_lte: BigInt + managementFee_gte: BigInt + managementFee_lte: BigInt + maxRate_gte: BigInt + maxRate_lte: BigInt + creationTimestamp_gte: BigInt + creationTimestamp_lte: BigInt + performanceFeeAbdicated: Boolean + managementFeeAbdicated: Boolean + totalAssetsUsd_gte: Float + totalAssetsUsd_lte: Float + totalAssets_gte: BigInt + totalAssets_lte: BigInt + totalSupply_gte: BigInt + totalSupply_lte: BigInt + liquidityUsd_gte: Float + liquidityUsd_lte: Float + liquidity_gte: BigInt + liquidity_lte: BigInt + apy_gte: Float + apy_lte: Float + netApy_gte: Float + netApy_lte: Float + realAssetsUsd_gte: Float + realAssetsUsd_lte: Float + realAssets_gte: BigInt + realAssets_lte: BigInt + idleAssetsUsd_gte: Float + idleAssetsUsd_lte: Float + idleAssets_gte: BigInt + idleAssets_lte: BigInt +} + +enum VaultV2OrderBy { + Address + TotalAssets + TotalAssetsUsd + TotalSupply + Liquidity + LiquidityUsd + Apy + NetApy + RealAssets + RealAssetsUsd + IdleAssets + IdleAssetsUsd +} + +enum VaultV2AllocationEventOrderBy { + Timestamp + Assets +} + +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" +input VaultV2AllocationTransactionFilters { +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + type_in: [VaultV2AllocationEventType!] +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + adapter_in: [String!] +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + sender_in: [String!] +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + timestamp_gte: Int +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + timestamp_lte: Int +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + assets_gte: BigInt +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + assets_lte: BigInt +} + +enum VaultV2TransactionOrderBy { + Time + Shares +} + +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultV2TransactionFilters { +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [VaultV2TransactionType!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + hash: String +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + cursor: VaultV2TransactionCursorInput +} + +""" +Cursor anchor for Vault V2 transactions. +""" +input VaultV2TransactionCursorInput { +""" +Cursor anchor for Vault V2 transactions. +""" + txHash: HexString! +""" +Cursor anchor for Vault V2 transactions. +""" + logIndex: Int! +} + +enum CacheControlScope { + PUBLIC + PRIVATE +} diff --git a/api/morphographql/generated.go b/api/morphographql/generated.go new file mode 100644 index 00000000..53303fc0 --- /dev/null +++ b/api/morphographql/generated.go @@ -0,0 +1,420 @@ +// Code generated by github.com/Khan/genqlient, DO NOT EDIT. + +package morphographql + +import ( + "context" + + "github.com/Khan/genqlient/graphql" + "github.com/symbioticfi/vault-solver/api/morphographql/scalars" +) + +// MorphoDiscoverMarketsMarketsPaginatedMarkets includes the requested fields of the GraphQL type PaginatedMarkets. +type MorphoDiscoverMarketsMarketsPaginatedMarkets struct { + Items []MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket `json:"items"` +} + +// GetItems returns MorphoDiscoverMarketsMarketsPaginatedMarkets.Items, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarkets) GetItems() []MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket { + return v.Items +} + +// MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket includes the requested fields of the GraphQL type Market. +// The GraphQL type's documentation follows. +// +// Morpho Blue market +type MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket struct { + // On-chain market ID + MarketId string `json:"marketId"` + OracleAddress string `json:"oracleAddress"` + // IRM contract address + IrmAddress string `json:"irmAddress"` + // Liquidation LTV + Lltv scalars.BigIntString `json:"lltv"` + LoanAsset MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset `json:"loanAsset"` + CollateralAsset *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset `json:"collateralAsset"` + // Current state + State *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState `json:"state"` +} + +// GetMarketId returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.MarketId, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetMarketId() string { + return v.MarketId +} + +// GetOracleAddress returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.OracleAddress, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetOracleAddress() string { + return v.OracleAddress +} + +// GetIrmAddress returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.IrmAddress, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetIrmAddress() string { + return v.IrmAddress +} + +// GetLltv returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.Lltv, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetLltv() scalars.BigIntString { + return v.Lltv +} + +// GetLoanAsset returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.LoanAsset, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetLoanAsset() MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset { + return v.LoanAsset +} + +// GetCollateralAsset returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.CollateralAsset, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetCollateralAsset() *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset { + return v.CollateralAsset +} + +// GetState returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.State, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetState() *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState { + return v.State +} + +// MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset includes the requested fields of the GraphQL type Asset. +// The GraphQL type's documentation follows. +// +// Asset +type MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset struct { + // ERC-20 token contract address + Address string `json:"address"` +} + +// GetAddress returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset.Address, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset) GetAddress() string { + return v.Address +} + +// MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset includes the requested fields of the GraphQL type Asset. +// The GraphQL type's documentation follows. +// +// Asset +type MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset struct { + // ERC-20 token contract address + Address string `json:"address"` +} + +// GetAddress returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset.Address, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset) GetAddress() string { + return v.Address +} + +// MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState includes the requested fields of the GraphQL type MarketState. +// The GraphQL type's documentation follows. +// +// Morpho Blue market state +type MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState struct { + // Block number of the state + BlockNumber scalars.BigIntString `json:"blockNumber"` + // Amount borrowed on the market, in underlying units. Amount increases as interests accrue. + BorrowAssets scalars.BigIntString `json:"borrowAssets"` + // Amount borrowed on the market, in market share units. Amount does not increase as interest accrue. + BorrowShares scalars.BigIntString `json:"borrowShares"` + // Amount supplied on the market, in underlying units. Amount increases as interests accrue. + SupplyAssets scalars.BigIntString `json:"supplyAssets"` + // Amount supplied on the market, in market share units. Amount does not increase as interest accrue. + SupplyShares scalars.BigIntString `json:"supplyShares"` + // Last update timestamp. + Timestamp scalars.BigIntString `json:"timestamp"` + // Collateral price + Price *scalars.BigIntString `json:"price"` +} + +// GetBlockNumber returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.BlockNumber, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetBlockNumber() scalars.BigIntString { + return v.BlockNumber +} + +// GetBorrowAssets returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.BorrowAssets, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetBorrowAssets() scalars.BigIntString { + return v.BorrowAssets +} + +// GetBorrowShares returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.BorrowShares, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetBorrowShares() scalars.BigIntString { + return v.BorrowShares +} + +// GetSupplyAssets returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.SupplyAssets, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetSupplyAssets() scalars.BigIntString { + return v.SupplyAssets +} + +// GetSupplyShares returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.SupplyShares, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetSupplyShares() scalars.BigIntString { + return v.SupplyShares +} + +// GetTimestamp returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.Timestamp, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetTimestamp() scalars.BigIntString { + return v.Timestamp +} + +// GetPrice returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.Price, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetPrice() *scalars.BigIntString { + return v.Price +} + +// MorphoDiscoverMarketsResponse is returned by MorphoDiscoverMarkets on success. +type MorphoDiscoverMarketsResponse struct { + Markets MorphoDiscoverMarketsMarketsPaginatedMarkets `json:"markets"` +} + +// GetMarkets returns MorphoDiscoverMarketsResponse.Markets, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsResponse) GetMarkets() MorphoDiscoverMarketsMarketsPaginatedMarkets { + return v.Markets +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions includes the requested fields of the GraphQL type PaginatedMarketPositions. +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions struct { + Items []MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition `json:"items"` +} + +// GetItems returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions.Items, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions) GetItems() []MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition { + return v.Items +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition includes the requested fields of the GraphQL type MarketPosition. +// The GraphQL type's documentation follows. +// +// Market position +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition struct { + User MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser `json:"user"` + Market MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket `json:"market"` + // Current state + State *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState `json:"state"` + // Health factor of the position, computed as collateral value divided by borrow value. + HealthFactor *float64 `json:"healthFactor"` +} + +// GetUser returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition.User, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition) GetUser() MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser { + return v.User +} + +// GetMarket returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition.Market, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition) GetMarket() MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket { + return v.Market +} + +// GetState returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition.State, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition) GetState() *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState { + return v.State +} + +// GetHealthFactor returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition.HealthFactor, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition) GetHealthFactor() *float64 { + return v.HealthFactor +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket includes the requested fields of the GraphQL type Market. +// The GraphQL type's documentation follows. +// +// Morpho Blue market +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket struct { + // On-chain market ID + MarketId string `json:"marketId"` +} + +// GetMarketId returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket.MarketId, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket) GetMarketId() string { + return v.MarketId +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState includes the requested fields of the GraphQL type MarketPositionState. +// The GraphQL type's documentation follows. +// +// Market position state +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState struct { + // The latest borrow shares indexed for this position. + BorrowShares scalars.BigIntString `json:"borrowShares"` + // The latest collateral assets indexed for this position. + Collateral scalars.BigIntString `json:"collateral"` +} + +// GetBorrowShares returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState.BorrowShares, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState) GetBorrowShares() scalars.BigIntString { + return v.BorrowShares +} + +// GetCollateral returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState.Collateral, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState) GetCollateral() scalars.BigIntString { + return v.Collateral +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser includes the requested fields of the GraphQL type User. +// The GraphQL type's documentation follows. +// +// User +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser struct { + Address string `json:"address"` +} + +// GetAddress returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser.Address, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser) GetAddress() string { + return v.Address +} + +// MorphoPositionsByMarketResponse is returned by MorphoPositionsByMarket on success. +type MorphoPositionsByMarketResponse struct { + MarketPositions MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions `json:"marketPositions"` +} + +// GetMarketPositions returns MorphoPositionsByMarketResponse.MarketPositions, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketResponse) GetMarketPositions() MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions { + return v.MarketPositions +} + +// __MorphoDiscoverMarketsInput is used internally by genqlient +type __MorphoDiscoverMarketsInput struct { + Loan []string `json:"loan"` + Coll []string `json:"coll"` + Chains []int `json:"chains"` + First int `json:"first"` +} + +// GetLoan returns __MorphoDiscoverMarketsInput.Loan, and is useful for accessing the field via an interface. +func (v *__MorphoDiscoverMarketsInput) GetLoan() []string { return v.Loan } + +// GetColl returns __MorphoDiscoverMarketsInput.Coll, and is useful for accessing the field via an interface. +func (v *__MorphoDiscoverMarketsInput) GetColl() []string { return v.Coll } + +// GetChains returns __MorphoDiscoverMarketsInput.Chains, and is useful for accessing the field via an interface. +func (v *__MorphoDiscoverMarketsInput) GetChains() []int { return v.Chains } + +// GetFirst returns __MorphoDiscoverMarketsInput.First, and is useful for accessing the field via an interface. +func (v *__MorphoDiscoverMarketsInput) GetFirst() int { return v.First } + +// __MorphoPositionsByMarketInput is used internally by genqlient +type __MorphoPositionsByMarketInput struct { + Ids []string `json:"ids"` + First int `json:"first"` + Skip int `json:"skip"` + MaxHf *float64 `json:"maxHf"` +} + +// GetIds returns __MorphoPositionsByMarketInput.Ids, and is useful for accessing the field via an interface. +func (v *__MorphoPositionsByMarketInput) GetIds() []string { return v.Ids } + +// GetFirst returns __MorphoPositionsByMarketInput.First, and is useful for accessing the field via an interface. +func (v *__MorphoPositionsByMarketInput) GetFirst() int { return v.First } + +// GetSkip returns __MorphoPositionsByMarketInput.Skip, and is useful for accessing the field via an interface. +func (v *__MorphoPositionsByMarketInput) GetSkip() int { return v.Skip } + +// GetMaxHf returns __MorphoPositionsByMarketInput.MaxHf, and is useful for accessing the field via an interface. +func (v *__MorphoPositionsByMarketInput) GetMaxHf() *float64 { return v.MaxHf } + +// The query executed by MorphoDiscoverMarkets. +const MorphoDiscoverMarkets_Operation = ` +query MorphoDiscoverMarkets ($loan: [String!]!, $coll: [String!]!, $chains: [Int!]!, $first: Int!) { + markets(first: $first, where: {loanAssetAddress_in:$loan,collateralAssetAddress_in:$coll,chainId_in:$chains}) { + items { + marketId + oracleAddress + irmAddress + lltv + loanAsset { + address + } + collateralAsset { + address + } + state { + blockNumber + borrowAssets + borrowShares + supplyAssets + supplyShares + timestamp + price + } + } + } +} +` + +func MorphoDiscoverMarkets( + ctx_ context.Context, + client_ graphql.Client, + loan []string, + coll []string, + chains []int, + first int, +) (data_ *MorphoDiscoverMarketsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "MorphoDiscoverMarkets", + Query: MorphoDiscoverMarkets_Operation, + Variables: &__MorphoDiscoverMarketsInput{ + Loan: loan, + Coll: coll, + Chains: chains, + First: first, + }, + } + + data_ = &MorphoDiscoverMarketsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by MorphoPositionsByMarket. +const MorphoPositionsByMarket_Operation = ` +query MorphoPositionsByMarket ($ids: [String!]!, $first: Int!, $skip: Int!, $maxHf: Float) { + marketPositions(first: $first, skip: $skip, orderBy: HealthFactor, orderDirection: Asc, where: {marketUniqueKey_in:$ids,healthFactor_lte:$maxHf}) { + items { + user { + address + } + market { + marketId + } + state { + borrowShares + collateral + } + healthFactor + } + } +} +` + +func MorphoPositionsByMarket( + ctx_ context.Context, + client_ graphql.Client, + ids []string, + first int, + skip int, + maxHf *float64, +) (data_ *MorphoPositionsByMarketResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "MorphoPositionsByMarket", + Query: MorphoPositionsByMarket_Operation, + Variables: &__MorphoPositionsByMarketInput{ + Ids: ids, + First: first, + Skip: skip, + MaxHf: maxHf, + }, + } + + data_ = &MorphoPositionsByMarketResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} diff --git a/api/morphographql/scalars/scalars.go b/api/morphographql/scalars/scalars.go new file mode 100644 index 00000000..f07fd755 --- /dev/null +++ b/api/morphographql/scalars/scalars.go @@ -0,0 +1,35 @@ +package scalars + +import ( + "bytes" + "encoding/json" +) + +// BigIntString accepts Morpho's BigInt scalar as either a JSON string or number and stores decimal text. +type BigIntString string + +func (b *BigIntString) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + *b = "" + return nil + } + if data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *b = BigIntString(s) + return nil + } + var n json.Number + if err := json.Unmarshal(data, &n); err != nil { + return err + } + *b = BigIntString(n.String()) + return nil +} + +func (b BigIntString) String() string { + return string(b) +} diff --git a/go.mod b/go.mod index 42f5c934..5cd7971d 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.26 toolchain go1.26.4 require ( + github.com/Khan/genqlient v0.8.1 github.com/danielgtaylor/huma/v2 v2.38.0 github.com/ethereum/go-ethereum v1.17.3 github.com/getsentry/sentry-go v0.46.2 github.com/go-errors/errors v1.5.1 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 + github.com/gorilla/websocket v1.5.0 github.com/prometheus/client_golang v1.15.0 github.com/spf13/cobra v1.10.2 go.uber.org/zap v1.27.0 @@ -36,7 +38,6 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect @@ -48,6 +49,7 @@ require ( github.com/supranational/blst v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/vektah/gqlparser/v2 v2.5.19 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect diff --git a/go.sum b/go.sum index 9517c2c9..5e39ce84 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= +github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= @@ -8,6 +10,8 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 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.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= @@ -98,8 +102,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= @@ -186,6 +190,8 @@ github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -205,6 +211,8 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/vektah/gqlparser/v2 v2.5.19 h1:bhCPCX1D4WWzCDvkPl4+TP1N8/kLrWnp43egplt7iSg= +github.com/vektah/gqlparser/v2 v2.5.19/go.mod h1:y7kvl5bBlDeuWIvLtA9849ncyvx6/lj06RsMrEjVy3U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= diff --git a/openapi/redstone-oev-ws.zod.ts b/openapi/redstone-oev-ws.zod.ts new file mode 100644 index 00000000..069ec29d --- /dev/null +++ b/openapi/redstone-oev-ws.zod.ts @@ -0,0 +1,68 @@ +// Vendored verbatim from RedStone (received via direct chat, 2026-06-12). +// Contract-of-record for the RedStone Atom OEV WebSocket messages, per the repo's +// vendor-the-source rule (CLAUDE.md "Code generation"). Not built or executed here — +// the Go structs in internal/solvers/redstoneoev/ are pinned to this file by tests. +// +// KNOWN GAP: RedStone has not (yet) shared the schema of the inbound auction broadcast +// (`op: "auction"`, incl. the liquidations-mode positions/prices payload). Until they do, +// that frame's contract-of-record is the docs example + live frames captured in P0 +// (see docs/OEV-PLAN.md §6.1, §6.3) — formalized in openapi/redstone-oev.asyncapi.yaml. +// +// Fields RedStone's schema adds beyond the public docs: +// solve.data.borrowers?: string[] — semantics unconfirmed (asked; likely telemetry/validation) +// solve.data.profit?: string — semantics unconfirmed (asked) +// liquidation-result.data.error?: string + +const WsMessageSubSchema = z.object({ + op: z.literal('subscribe'), + topic: z.string(), +}); + +const WsMessageUnSubSchema = z.object({ + op: z.literal('unsubscribe'), + topic: z.string(), +}); + +const WsMessageSolveSchema = z.object({ + op: z.literal('solve'), + id: z.string(), + data: z.object({ + bid: z.string(), + nonce: z.string(), + operationCallback: z.string(), + operationData: z.string(), + liquidationSig: z.string(), + maxTxGasPrice: z.string(), + borrowers: z.array(z.string()).optional(), + profit: z.string().optional(), + }), +}); + +const WsMessageAuctionResult = z.object({ + op: z.literal('auction-result'), + id: z.string(), + data: z.object({ + bid: z.string(), + liquidator: z.string(), + }), +}); + +const WsMessageLiquidationResult = z.object({ + op: z.literal('liquidation-result'), + id: z.string(), + data: z.object({ + success: z.boolean(), + txHash: z.string(), + liquidator: z.string(), + error: z.string().optional(), + }), +}); + +const WsMessageBlacklist = z.object({ + op: z.literal('blacklisted'), + id: z.string(), + data: z.object({ + liquidator: z.string(), + msg: z.string(), + }), +}); From c615d0cfd499559dbba2dfcf3e8c80e345c37410 Mon Sep 17 00:00:00 2001 From: alrxy Date: Fri, 26 Jun 2026 18:58:51 +0700 Subject: [PATCH 07/50] refactor: add shared parsing and Morpho helpers --- internal/chain/bigmath.go | 11 + internal/chain/chain.go | 18 +- internal/chain/decimals.go | 110 +++++++ internal/morpho/math.go | 302 +++++++++++++++++++ internal/morpho/math_test.go | 264 ++++++++++++++++ internal/parse/parse.go | 116 +++++++ internal/parse/parse_test.go | 215 +++++++++++++ internal/solvers/bridgefacilitator/config.go | 46 +-- internal/solvers/rfq/apitypes.go | 10 +- internal/solvers/rfq/chainreader.go | 43 +-- internal/solvers/rfq/config.go | 34 +-- internal/solvers/rfq/config_test.go | 16 +- internal/solvers/rfq/store.go | 4 +- internal/solvers/rfq/strategy.go | 10 +- 14 files changed, 1074 insertions(+), 125 deletions(-) create mode 100644 internal/chain/bigmath.go create mode 100644 internal/chain/decimals.go create mode 100644 internal/morpho/math.go create mode 100644 internal/morpho/math_test.go create mode 100644 internal/parse/parse.go create mode 100644 internal/parse/parse_test.go diff --git a/internal/chain/bigmath.go b/internal/chain/bigmath.go new file mode 100644 index 00000000..4660d711 --- /dev/null +++ b/internal/chain/bigmath.go @@ -0,0 +1,11 @@ +package chain + +import "math/big" + +// Exp10 returns 10^n as a *big.Int — the canonical power-of-ten / token-decimals scale used across the +// solvers for fixed-point conversions (e.g. scaling between tokens of different decimals). n must be +// >= 0; negative exponents are not meaningful for decimal scales and yield 1 (big.Int.Exp on a negative +// exponent with a nil modulus returns 1). +func Exp10(n int) *big.Int { + return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil) +} diff --git a/internal/chain/chain.go b/internal/chain/chain.go index 22399d01..4994048b 100644 --- a/internal/chain/chain.go +++ b/internal/chain/chain.go @@ -11,7 +11,7 @@ import ( "github.com/go-errors/errors" "github.com/go-logr/logr" - "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" @@ -19,6 +19,9 @@ import ( "github.com/symbioticfi/vault-solver/api/bindings/multicall3" ) +// multicallB is the stateless v2 aggregate3 pack/unpack binding (no backend). +var multicallB = multicall3.NewMulticall3() + // Client is an ethclient.Client plus the chain id and the Multicall3 address, cached at dial time. type Client struct { *ethclient.Client @@ -92,20 +95,21 @@ type CallResult struct { ReturnData []byte } -// Multicall batches reads through Multicall3.aggregate3, collapsing N eth_calls into one round-trip. +// Multicall batches reads through Multicall3.aggregate3 at the latest block. func (c *Client) Multicall(ctx context.Context, calls []Call) ([]CallResult, error) { - caller, err := multicall3.NewMulticall3Caller(c.multicall, c.Client) - if err != nil { - return nil, errors.Errorf("chain: bind multicall3 %s: %w", c.multicall, err) - } in := make([]multicall3.Multicall3Call3, len(calls)) for i, call := range calls { in[i] = multicall3.Multicall3Call3{Target: call.Target, AllowFailure: call.AllowFailure, CallData: call.Data} } - out, err := caller.Aggregate3(&bind.CallOpts{Context: ctx}, in) + data := multicallB.PackAggregate3(in) + ret, err := c.CallContract(ctx, ethereum.CallMsg{To: &c.multicall, Data: data}, nil) if err != nil { return nil, errors.Errorf("chain: multicall aggregate3: %w", err) } + out, err := multicallB.UnpackAggregate3(ret) + if err != nil { + return nil, errors.Errorf("chain: multicall unpack aggregate3: %w", err) + } res := make([]CallResult, len(out)) for i, o := range out { res[i] = CallResult{Success: o.Success, ReturnData: o.ReturnData} diff --git a/internal/chain/decimals.go b/internal/chain/decimals.go new file mode 100644 index 00000000..1bec27b9 --- /dev/null +++ b/internal/chain/decimals.go @@ -0,0 +1,110 @@ +package chain + +import ( + "context" + "slices" + "sync" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/bindings/erc20" +) + +// erc20B is the generated minimal ERC-20 binding (decimals() only) this cache packs/unpacks through. +var erc20B = erc20.NewERC20() + +// Decimals is a concurrency-safe, multicall-backed cache of ERC-20 token decimals. Token decimals are +// immutable, so a value is read once and memoized. Solvers serve quotes/refreshes concurrently, so the +// cache is mutex-guarded. It is a generic cross-solver primitive (both the RFQ and OEV readers need +// it), so it lives in the chain layer next to Multicall. +type Decimals struct { + chain *Client + mu sync.Mutex + cache map[common.Address]int +} + +// NewDecimals builds a decimals cache over the given client. +func NewDecimals(c *Client) *Decimals { + return &Decimals{chain: c, cache: make(map[common.Address]int)} +} + +// Get returns token's decimals, reading (and caching) it on a miss. +func (d *Decimals) Get(ctx context.Context, token common.Address) (int, error) { + d.mu.Lock() + if v, ok := d.cache[token]; ok { + d.mu.Unlock() + return v, nil + } + d.mu.Unlock() + + res, err := d.chain.Multicall(ctx, []Call{{Target: token, Data: erc20B.PackDecimals()}}) + if err != nil { + return 0, err + } + if len(res) != 1 || !res[0].Success { + return 0, errors.Errorf("erc20.decimals() reverted for %s", token) + } + v, err := decodeDecimals(res[0].ReturnData) + if err != nil { + return 0, err + } + d.store(token, v) + return v, nil +} + +// GetMany returns decimals for several tokens, reading every uncached one in a SINGLE multicall (so +// resolving N new tokens costs one round-trip, not N). Cached tokens are served without any call. It +// is lenient per token: a token whose decimals() reverts is simply omitted from the result (the caller +// fails that item closed) rather than failing the whole batch — only a transport error is returned. +func (d *Decimals) GetMany(ctx context.Context, tokens []common.Address) (map[common.Address]int, error) { + out := make(map[common.Address]int, len(tokens)) + var miss []common.Address + d.mu.Lock() + for _, t := range tokens { + if v, ok := d.cache[t]; ok { + out[t] = v + } else if !slices.Contains(miss, t) { + miss = append(miss, t) + } + } + d.mu.Unlock() + if len(miss) == 0 { + return out, nil + } + + calls := make([]Call, len(miss)) + for i, t := range miss { + calls[i] = Call{Target: t, AllowFailure: true, Data: erc20B.PackDecimals()} + } + res, err := d.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + for i, t := range miss { + if i >= len(res) || !res[i].Success { + continue // token's decimals() reverted — omit; caller skips that market (fail closed) + } + v, derr := decodeDecimals(res[i].ReturnData) + if derr != nil { + continue + } + d.store(t, v) + out[t] = v + } + return out, nil +} + +func (d *Decimals) store(token common.Address, v int) { + d.mu.Lock() + d.cache[token] = v + d.mu.Unlock() +} + +func decodeDecimals(data []byte) (int, error) { + v, err := erc20B.UnpackDecimals(data) + if err != nil { + return 0, errors.Errorf("unpack decimals: %w", err) + } + return int(v), nil +} diff --git a/internal/morpho/math.go b/internal/morpho/math.go new file mode 100644 index 00000000..e78b6c91 --- /dev/null +++ b/internal/morpho/math.go @@ -0,0 +1,302 @@ +package morpho + +import ( + "math/big" + + "github.com/symbioticfi/vault-solver/internal/chain" +) + +// Morpho Blue math, ported verbatim from morpho-org/morpho-blue (see docs/OEV-PLAN.md §6.4). This is +// the SINGLE source of truth for health/sizing over a worker-derived candidate set. All arithmetic is +// big.Int with the exact rounding directions Morpho uses on-chain; an off-by-one here means reverted or +// unprofitable fills. Lives in internal/morpho so any solver can reuse it. + +// WAD-scaled constants (1e18 fixed point) and Morpho library constants. +var ( + one = big.NewInt(1) // reused divisor adjustment in MulDivUp (avoids a per-call alloc) + Wad = big.NewInt(1e18) + twoWad = big.NewInt(2e18) // 2·WAD — Taylor-series denominators, hoisted out of the hot path + threeWad = big.NewInt(3e18) // 3·WAD + oraclePriceScale = chain.Exp10(36) // ORACLE_PRICE_SCALE = 1e36 + virtualShares = big.NewInt(1e6) // SharesMathLib.VIRTUAL_SHARES + virtualAssets = big.NewInt(1) // SharesMathLib.VIRTUAL_ASSETS + liquidationCursor = big.NewInt(0.3e18) // ConstantsLib.LIQUIDATION_CURSOR (β) + maxLiqIncentive = big.NewInt(1.15e18) // ConstantsLib.MAX_LIQUIDATION_INCENTIVE_FACTOR (M) +) + +// MarketState is the on-chain market accounting (Morpho `market(id)`), plus the IRM rate needed to +// accrue interest locally. Amounts are big.Int (uint128 on-chain). +type MarketState struct { + TotalSupplyAssets *big.Int + TotalSupplyShares *big.Int + TotalBorrowAssets *big.Int + TotalBorrowShares *big.Int + LastUpdate uint64 + Fee *big.Int + Lltv *big.Int // from idToMarketParams; the market's liquidation LTV (wad) + BorrowRatePerSec *big.Int // IRM borrowRateView (wad/sec); zero ⇒ no accrual (irm == 0) +} + +// PositionState is a borrower's position (Morpho `position(id, borrower)`). +type PositionState struct { + BorrowShares *big.Int + Collateral *big.Int +} + +// LiquidationReplay is the local post-state of Morpho's seize-driven liquidate branch. +type LiquidationReplay struct { + Market MarketState + Position PositionState + RepaidAssets *big.Int + RepaidShares *big.Int + BadDebtAssets *big.Int + BadDebtShares *big.Int +} + +// AccruedTotalBorrowAssets returns totalBorrowAssets grown to `nowTs` using the Taylor-compounded +// borrow rate — the off-chain replica of Morpho `_accrueInterest` (borrow shares are unchanged by +// accrual; only the assets side grows). Returns the original value when elapsed is 0 or the rate is 0. +func AccruedTotalBorrowAssets(m MarketState, nowTs uint64) *big.Int { + tba := new(big.Int).Set(m.TotalBorrowAssets) + if m.BorrowRatePerSec == nil || m.BorrowRatePerSec.Sign() == 0 || nowTs <= m.LastUpdate { + return tba + } + elapsed := new(big.Int).SetUint64(nowTs - m.LastUpdate) + growth := WTaylorCompounded(m.BorrowRatePerSec, elapsed) + interest := WMulDown(tba, growth) + return tba.Add(tba, interest) +} + +// AccruedMarketState returns Morpho's market accounting after `_accrueInterest`, including the supply side +// needed for bad-debt replay. Borrow shares never change on accrual. +func AccruedMarketState(m MarketState, nowTs uint64) MarketState { + out := cloneMarketState(m) + if out.BorrowRatePerSec == nil || out.BorrowRatePerSec.Sign() == 0 || nowTs <= out.LastUpdate { + return out + } + elapsed := new(big.Int).SetUint64(nowTs - out.LastUpdate) + growth := WTaylorCompounded(out.BorrowRatePerSec, elapsed) + interest := WMulDown(out.TotalBorrowAssets, growth) + out.TotalBorrowAssets.Add(out.TotalBorrowAssets, interest) + out.TotalSupplyAssets.Add(out.TotalSupplyAssets, interest) + if out.Fee != nil && out.Fee.Sign() != 0 { + feeAmount := WMulDown(interest, out.Fee) + supplyExFee := new(big.Int).Sub(out.TotalSupplyAssets, feeAmount) + feeShares := ToSharesDown(feeAmount, supplyExFee, out.TotalSupplyShares) + out.TotalSupplyShares.Add(out.TotalSupplyShares, feeShares) + } + out.LastUpdate = nowTs + return out +} + +// BorrowedAssetsAt is BorrowedAssets given a pre-accrued total — so the hot path can accrue once per +// candidate and reuse it across the health check and sizing instead of recomputing the Taylor series. +func BorrowedAssetsAt(p PositionState, accruedTotal, totalShares *big.Int) *big.Int { + if p.BorrowShares == nil || p.BorrowShares.Sign() == 0 { + return big.NewInt(0) + } + return ToAssetsUp(p.BorrowShares, accruedTotal, totalShares) +} + +// MaxBorrow returns the largest debt the position may carry at `collateralPrice` (1e36-scaled), +// rounding down in the protocol's favor: collateral.mulDivDown(price, 1e36).wMulDown(lltv). +func MaxBorrow(collateral, collateralPrice, lltv *big.Int) *big.Int { + return WMulDown(MulDivDown(collateral, collateralPrice, oraclePriceScale), lltv) +} + +// IsLiquidatableAt is IsLiquidatable given a pre-accrued total (hot-path variant). +func IsLiquidatableAt(p PositionState, collateralPrice, lltv, accruedTotal, totalShares *big.Int) bool { + borrowed := BorrowedAssetsAt(p, accruedTotal, totalShares) + if borrowed.Sign() == 0 { + return false + } + return MaxBorrow(p.Collateral, collateralPrice, lltv).Cmp(borrowed) < 0 +} + +// LiquidationProximity returns the two quantities whose ratio is the position's distance to liquidation: +// borrowed = BorrowedAssetsAt(p, …) and maxBorrow = MaxBorrow(p.Collateral, …). Higher borrowed/maxBorrow +// ⇒ closer to (or past) liquidation; borrowed >= maxBorrow is exactly the IsLiquidatableAt boundary. A +// caller ranks without dividing by cross-multiplying the two pairs (no float, no division). +func LiquidationProximity(p PositionState, collateralPrice, lltv, accruedTotal, totalShares *big.Int) (borrowed, maxBorrow *big.Int) { + return BorrowedAssetsAt(p, accruedTotal, totalShares), MaxBorrow(p.Collateral, collateralPrice, lltv) +} + +// LiquidationIncentiveFactor = min(M, 1 / (1 - cursor*(1 - lltv))) in wad, matching liquidate(). +func LiquidationIncentiveFactor(lltv *big.Int) *big.Int { + // WAD.wDivDown(WAD - LIQUIDATION_CURSOR.wMulDown(WAD - lltv)) + oneMinusLltv := new(big.Int).Sub(Wad, lltv) + denom := new(big.Int).Sub(Wad, WMulDown(liquidationCursor, oneMinusLltv)) + lif := WDivDown(Wad, denom) + if lif.Cmp(maxLiqIncentive) > 0 { + return new(big.Int).Set(maxLiqIncentive) + } + return lif +} + +// RepaidAssetsForSeizeAt replicates liquidate()'s seize→shares→assets path with Morpho's rounding (quote +// up, divide by LIF up, shares up, assets up) given a pre-accrued total and the precomputed +// LiquidationIncentiveFactor — the hot-path variant (sizeLeg computes the LIF once and passes it here and +// to MaxSeizeForFullDebt). +func RepaidAssetsForSeizeAt(seizedAssets, collateralPrice, lif, accruedTotal, totalShares *big.Int) *big.Int { + seizedQuoted := MulDivUp(seizedAssets, collateralPrice, oraclePriceScale) + repaidShares := ToSharesUp(WDivUp(seizedQuoted, lif), accruedTotal, totalShares) + return ToAssetsUp(repaidShares, accruedTotal, totalShares) +} + +// ApplySeizeLiquidation replays Morpho Blue liquidate(market, borrower, seizedAssets, 0, data) on local +// state. It assumes m is already accrued to the settlement timestamp and returns ok=false for any state +// transition that would underflow or cannot be priced. +func ApplySeizeLiquidation(m MarketState, p PositionState, seizedAssets, collateralPrice *big.Int) (LiquidationReplay, bool) { + if seizedAssets == nil || seizedAssets.Sign() <= 0 || collateralPrice == nil || collateralPrice.Sign() <= 0 || + m.TotalBorrowAssets == nil || m.TotalBorrowShares == nil || m.TotalSupplyAssets == nil || + p.BorrowShares == nil || p.Collateral == nil || m.Lltv == nil { + return LiquidationReplay{}, false + } + lif := LiquidationIncentiveFactor(m.Lltv) + seizedQuoted := MulDivUp(seizedAssets, collateralPrice, oraclePriceScale) + repaidShares := ToSharesUp(WDivUp(seizedQuoted, lif), m.TotalBorrowAssets, m.TotalBorrowShares) + repaidAssets := ToAssetsUp(repaidShares, m.TotalBorrowAssets, m.TotalBorrowShares) + if p.BorrowShares.Cmp(repaidShares) < 0 || m.TotalBorrowShares.Cmp(repaidShares) < 0 || p.Collateral.Cmp(seizedAssets) < 0 { + return LiquidationReplay{}, false + } + out := LiquidationReplay{ + Market: cloneMarketState(m), + Position: clonePositionState(p), + RepaidAssets: repaidAssets, + RepaidShares: repaidShares, + BadDebtAssets: new(big.Int), + BadDebtShares: new(big.Int), + } + out.Position.BorrowShares.Sub(out.Position.BorrowShares, repaidShares) + out.Market.TotalBorrowShares.Sub(out.Market.TotalBorrowShares, repaidShares) + out.Market.TotalBorrowAssets = zeroFloorSub(out.Market.TotalBorrowAssets, repaidAssets) + out.Position.Collateral.Sub(out.Position.Collateral, seizedAssets) + if out.Position.Collateral.Sign() == 0 { + out.BadDebtShares = new(big.Int).Set(out.Position.BorrowShares) + out.BadDebtAssets = minBig(out.Market.TotalBorrowAssets, ToAssetsUp(out.BadDebtShares, out.Market.TotalBorrowAssets, out.Market.TotalBorrowShares)) + if out.Market.TotalSupplyAssets.Cmp(out.BadDebtAssets) < 0 || out.Market.TotalBorrowShares.Cmp(out.BadDebtShares) < 0 { + return LiquidationReplay{}, false + } + out.Market.TotalBorrowAssets.Sub(out.Market.TotalBorrowAssets, out.BadDebtAssets) + out.Market.TotalSupplyAssets.Sub(out.Market.TotalSupplyAssets, out.BadDebtAssets) + out.Market.TotalBorrowShares.Sub(out.Market.TotalBorrowShares, out.BadDebtShares) + out.Position.BorrowShares = new(big.Int) + } + return out, true +} + +// MaxSeizeForFullDebt is the largest collateral seize whose implied repayment never exceeds the borrower's +// outstanding debt — the inverse of RepaidAssetsForSeizeAt at the full-debt point. It mirrors Morpho +// liquidate()'s shares→seize path (the branch where repaidShares is the input): seize the full borrow +// shares back through assets-down → ×LIF down → ÷price down. Every step rounds DOWN, so the resulting seize +// repays AT MOST the full debt — a full liquidation clamps to this and can never round up past the debt +// (which would underflow borrowShares and revert). The leg's seize target is min(its fraction, this). lif is +// the precomputed LiquidationIncentiveFactor (sizeLeg computes it once for both this and +// RepaidAssetsForSeizeAt). +func MaxSeizeForFullDebt(borrowShares, collateralPrice, lif, accruedTotal, totalShares *big.Int) *big.Int { + if borrowShares == nil || borrowShares.Sign() <= 0 || collateralPrice == nil || collateralPrice.Sign() <= 0 { + return new(big.Int) + } + debtAssets := ToAssetsDown(borrowShares, accruedTotal, totalShares) + return MulDivDown(WMulDown(debtAssets, lif), oraclePriceScale, collateralPrice) +} + +/* ───────── whole-market convenience forms (accrue once, then forward) ───────── */ + +// BorrowedAssets accrues the market to nowTs, then forwards to BorrowedAssetsAt. The hot path accrues once +// and calls the *At forms directly; these whole-market forms are for callers (and tests) holding a raw state. +func BorrowedAssets(m MarketState, p PositionState, nowTs uint64) *big.Int { + return BorrowedAssetsAt(p, AccruedTotalBorrowAssets(m, nowTs), m.TotalBorrowShares) +} + +// IsLiquidatable accrues the market to nowTs, then forwards to IsLiquidatableAt. +func IsLiquidatable(m MarketState, p PositionState, collateralPrice *big.Int, nowTs uint64) bool { + return IsLiquidatableAt(p, collateralPrice, m.Lltv, AccruedTotalBorrowAssets(m, nowTs), m.TotalBorrowShares) +} + +// RepaidAssetsForSeize accrues the market to nowTs, then forwards to RepaidAssetsForSeizeAt. +func RepaidAssetsForSeize(m MarketState, seizedAssets, collateralPrice, lltv *big.Int, nowTs uint64) *big.Int { + return RepaidAssetsForSeizeAt(seizedAssets, collateralPrice, LiquidationIncentiveFactor(lltv), AccruedTotalBorrowAssets(m, nowTs), m.TotalBorrowShares) +} + +/* ───────── SharesMathLib (virtual shares/assets) ───────── */ + +func ToSharesUp(assets, totalAssets, totalShares *big.Int) *big.Int { + return MulDivUp(assets, new(big.Int).Add(totalShares, virtualShares), new(big.Int).Add(totalAssets, virtualAssets)) +} + +func ToAssetsUp(shares, totalAssets, totalShares *big.Int) *big.Int { + return MulDivUp(shares, new(big.Int).Add(totalAssets, virtualAssets), new(big.Int).Add(totalShares, virtualShares)) +} + +func ToSharesDown(assets, totalAssets, totalShares *big.Int) *big.Int { + return MulDivDown(assets, new(big.Int).Add(totalShares, virtualShares), new(big.Int).Add(totalAssets, virtualAssets)) +} + +// ToAssetsDown is SharesMathLib.toAssetsDown — used by liquidate()'s shares→seize path (MaxSeizeForFullDebt). +func ToAssetsDown(shares, totalAssets, totalShares *big.Int) *big.Int { + return MulDivDown(shares, new(big.Int).Add(totalAssets, virtualAssets), new(big.Int).Add(totalShares, virtualShares)) +} + +/* ───────── MathLib (wad + mulDiv) ───────── */ + +func WTaylorCompounded(ratePerSec, n *big.Int) *big.Int { + // firstTerm = x*n; second = firstTerm²/(2·WAD); third = second·firstTerm/(3·WAD) + first := new(big.Int).Mul(ratePerSec, n) + second := MulDivDown(first, first, twoWad) + third := MulDivDown(second, first, threeWad) + return new(big.Int).Add(new(big.Int).Add(first, second), third) +} + +func WMulDown(x, y *big.Int) *big.Int { return MulDivDown(x, y, Wad) } +func WDivDown(x, y *big.Int) *big.Int { return MulDivDown(x, Wad, y) } +func WDivUp(x, y *big.Int) *big.Int { return MulDivUp(x, Wad, y) } + +func MulDivDown(x, y, d *big.Int) *big.Int { + return new(big.Int).Div(new(big.Int).Mul(x, y), d) +} + +func MulDivUp(x, y, d *big.Int) *big.Int { + // (x*y + d - 1) / d + num := new(big.Int).Mul(x, y) + num.Add(num, new(big.Int).Sub(d, one)) + return num.Div(num, d) +} + +func cloneMarketState(m MarketState) MarketState { + return MarketState{ + TotalSupplyAssets: cloneBig(m.TotalSupplyAssets), + TotalSupplyShares: cloneBig(m.TotalSupplyShares), + TotalBorrowAssets: cloneBig(m.TotalBorrowAssets), + TotalBorrowShares: cloneBig(m.TotalBorrowShares), + LastUpdate: m.LastUpdate, + Fee: cloneBig(m.Fee), + Lltv: cloneBig(m.Lltv), + BorrowRatePerSec: cloneBig(m.BorrowRatePerSec), + } +} + +func clonePositionState(p PositionState) PositionState { + return PositionState{BorrowShares: cloneBig(p.BorrowShares), Collateral: cloneBig(p.Collateral)} +} + +func cloneBig(n *big.Int) *big.Int { + if n == nil { + return nil + } + return new(big.Int).Set(n) +} + +func zeroFloorSub(x, y *big.Int) *big.Int { + if x.Cmp(y) <= 0 { + return new(big.Int) + } + return new(big.Int).Sub(x, y) +} + +func minBig(a, b *big.Int) *big.Int { + if a.Cmp(b) <= 0 { + return new(big.Int).Set(a) + } + return new(big.Int).Set(b) +} diff --git a/internal/morpho/math_test.go b/internal/morpho/math_test.go new file mode 100644 index 00000000..4c0514f4 --- /dev/null +++ b/internal/morpho/math_test.go @@ -0,0 +1,264 @@ +package morpho + +import ( + "math/big" + "testing" +) + +// goldenMarket is the live Sepolia test market state read on-chain (docs/OEV-PLAN.md §6.5/§6.7): +// TLOAN(6dp)/TCOL(18dp), lltv 0.86, IRM borrowRateView = 182418302 wad/sec, lastUpdate 1780059204. +func goldenMarket() MarketState { + return MarketState{ + TotalSupplyAssets: big.NewInt(100000000068), + TotalSupplyShares: mustBig("100000000000000000"), + TotalBorrowAssets: big.NewInt(4730000068), + TotalBorrowShares: mustBig("4729999932892591"), + LastUpdate: 1780059204, + Fee: big.NewInt(0), + Lltv: mustBig("860000000000000000"), + BorrowRatePerSec: big.NewInt(182418302), + } +} + +// goldenBorrower is 0x629d… — 1.0 TCOL collateral, borrowShares 1685600000000000. +func goldenBorrower() PositionState { + return PositionState{BorrowShares: mustBig("1685600000000000"), Collateral: mustBig("1000000000000000000")} +} + +func TestAccrualMatchesOnChain(t *testing.T) { + m := goldenMarket() + // elapsed = 1781246580 - 1780059204 = 1187376s -> interest 1024624 (verified §6.7). + got := AccruedTotalBorrowAssets(m, 1781246580) + if want := big.NewInt(4731024692); got.Cmp(want) != 0 { + t.Fatalf("AccruedTotalBorrowAssets = %s, want %s", got, want) + } + // No accrual at lastUpdate. + if atLU := AccruedTotalBorrowAssets(m, m.LastUpdate); atLU.Cmp(m.TotalBorrowAssets) != 0 { + t.Fatalf("accrual at lastUpdate = %s, want %s", atLU, m.TotalBorrowAssets) + } + full := AccruedMarketState(m, 1781246580) + if want := big.NewInt(4731024692); full.TotalBorrowAssets.Cmp(want) != 0 { + t.Fatalf("AccruedMarketState borrow = %s, want %s", full.TotalBorrowAssets, want) + } + if want := big.NewInt(100001024692); full.TotalSupplyAssets.Cmp(want) != 0 { + t.Fatalf("AccruedMarketState supply = %s, want %s", full.TotalSupplyAssets, want) + } + if full.LastUpdate != 1781246580 { + t.Fatalf("AccruedMarketState lastUpdate = %d, want 1781246580", full.LastUpdate) + } +} + +func TestBorrowedAssetsUnaccrued(t *testing.T) { + // toAssetsUp at lastUpdate equals RedStone's pushed borrow_assets (1685600048) within 1-wei + // rounding (§6.7): our ToAssetsUp rounds up -> 1685600049. + got := BorrowedAssets(goldenMarket(), goldenBorrower(), goldenMarket().LastUpdate) + if want := big.NewInt(1685600049); got.Cmp(want) != 0 { + t.Fatalf("unaccrued borrowed = %s, want %s", got, want) + } +} + +func TestLiquidationIncentiveFactor(t *testing.T) { + // lltv 0.86 -> 1e36 / 0.958e18 = 1043841336116910229 (floor). + got := LiquidationIncentiveFactor(mustBig("860000000000000000")) + if want := mustBig("1043841336116910229"); got.Cmp(want) != 0 { + t.Fatalf("LIF = %s, want %s", got, want) + } +} + +// TestMaxSeizeForFullDebt pins the F2 clamp helper. The on-chain revert is a borrowShares underflow +// (position.borrowShares -= repaidShares), so the binding invariant is repaidShares(maxSeize) ≤ +// borrowShares — every inverse step rounds down so a full liquidation clamped to this can't underflow. It +// also stays ≤ the up-rounded debt (BorrowedAssetsAt), the proxy the strategy clamps against. +func TestMaxSeizeForFullDebt(t *testing.T) { + lltv := mustBig("500000000000000000") + price := mustBig("1000000000000000000000000000000000000") // 1e36 + totalAssets := mustBig("1000000000000000000000000") + totalShares := new(big.Int).Set(totalAssets) // 1:1 + lif := LiquidationIncentiveFactor(lltv) + for _, shares := range []*big.Int{ + mustBig("1"), mustBig("500000000000000001"), mustBig("123456789012345678"), mustBig("999999999999999999"), + } { + maxSeize := MaxSeizeForFullDebt(shares, price, lif, totalAssets, totalShares) + // The exact on-chain underflow condition: repaidShares must not exceed the borrower's borrowShares. + seizedQuoted := MulDivUp(maxSeize, price, oraclePriceScale) + repaidShares := ToSharesUp(WDivUp(seizedQuoted, lif), totalAssets, totalShares) + if repaidShares.Cmp(shares) > 0 { + t.Fatalf("MaxSeizeForFullDebt over-repays: shares=%s seize=%s repaidShares=%s > borrowShares=%s (underflow)", + shares, maxSeize, repaidShares, shares) + } + // And ≤ the up-rounded debt the strategy uses as its assets-level proxy. + debtUp := BorrowedAssetsAt(PositionState{BorrowShares: shares}, totalAssets, totalShares) + if repaid := RepaidAssetsForSeizeAt(maxSeize, price, lif, totalAssets, totalShares); repaid.Cmp(debtUp) > 0 { + t.Fatalf("repaidAssets %s > borrowerDebt(up) %s for shares=%s seize=%s", repaid, debtUp, shares, maxSeize) + } + } + // Degenerate inputs fail closed to 0 (no seize), not a panic. + if got := MaxSeizeForFullDebt(big.NewInt(0), price, lif, totalAssets, totalShares); got.Sign() != 0 { + t.Fatalf("zero borrowShares must give zero maxSeize, got %s", got) + } + if got := MaxSeizeForFullDebt(mustBig("1"), big.NewInt(0), lif, totalAssets, totalShares); got.Sign() != 0 { + t.Fatalf("zero price must give zero maxSeize, got %s", got) + } +} + +func TestIsLiquidatableAcrossPrices(t *testing.T) { + m := goldenMarket() + ts := uint64(1781246580) + cases := []struct { + name string + pos PositionState + price string + want bool + }{ + {"live 2000 healthy", goldenBorrower(), "2000000000000000000000000000", false}, // 2e27 + {"auctioned 1800.94 liquidatable", goldenBorrower(), "1800943620100000000000000000", true}, // 1.8009e27 + {"crashed 1550 liquidatable", goldenBorrower(), "1550000000000000000000000000", true}, // 1.55e27 + // Zero debt (BorrowShares=0) with collateral is healthy at ANY price — the debt-free branch through + // IsLiquidatable can never be underwater. Priced at the crash level that liquidates a debted position. + {"zero debt healthy at crash price", PositionState{BorrowShares: big.NewInt(0), Collateral: mustBig("1000000000000000000")}, "1550000000000000000000000000", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := IsLiquidatable(m, c.pos, mustBig(c.price), ts); got != c.want { + t.Fatalf("IsLiquidatable(%s) = %v, want %v", c.price, got, c.want) + } + }) + } +} + +// TestLiquidationProximity pins the proximity pair against BorrowedAssetsAt / MaxBorrow and checks that +// the borrowed >= maxBorrow boundary tracks IsLiquidatableAt. +func TestLiquidationProximity(t *testing.T) { + m := goldenMarket() + accrued := AccruedTotalBorrowAssets(m, m.LastUpdate) + cases := []struct { + name string + pos PositionState + price string + wantLiqable bool + }{ + {"healthy at 2000", goldenBorrower(), "2000000000000000000000000000", false}, + {"liquidatable at 1550", goldenBorrower(), "1550000000000000000000000000", true}, + {"zero debt", PositionState{BorrowShares: big.NewInt(0), Collateral: mustBig("1000000000000000000")}, "1550000000000000000000000000", false}, + {"underwater: zero maxBorrow with debt", goldenBorrower(), "0", true}, // price 0 ⇒ maxBorrow 0, borrowed > 0 + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + price := mustBig(c.price) + borrowed, maxBorrow := LiquidationProximity(c.pos, price, m.Lltv, accrued, m.TotalBorrowShares) + // The pair must equal the underlying helpers exactly. + if want := BorrowedAssetsAt(c.pos, accrued, m.TotalBorrowShares); borrowed.Cmp(want) != 0 { + t.Fatalf("borrowed = %s, want %s", borrowed, want) + } + if want := MaxBorrow(c.pos.Collateral, price, m.Lltv); maxBorrow.Cmp(want) != 0 { + t.Fatalf("maxBorrow = %s, want %s", maxBorrow, want) + } + // borrowed >= maxBorrow (with borrowed > 0) is the IsLiquidatableAt boundary. + boundary := borrowed.Sign() > 0 && borrowed.Cmp(maxBorrow) >= 0 + if boundary != c.wantLiqable { + t.Fatalf("borrowed>=maxBorrow = %v (borrowed=%s maxBorrow=%s), want %v", boundary, borrowed, maxBorrow, c.wantLiqable) + } + if liq := IsLiquidatableAt(c.pos, price, m.Lltv, accrued, m.TotalBorrowShares); liq != c.wantLiqable { + t.Fatalf("IsLiquidatableAt = %v, want %v", liq, c.wantLiqable) + } + }) + } +} + +func TestRepaidAssetsForSeizeMatchesLiveLiquidation(t *testing.T) { + // The real successful liquidation (§6.6) seized 0.5 TCOL at $1550 and repaid ~742.45 TLOAN + // (swapAmountOut 760 - profit 17.55). Assert RepaidAssetsForSeize lands in that band. + m := goldenMarket() + got := RepaidAssetsForSeize(m, mustBig("500000000000000000"), mustBig("1550000000000000000000000000"), + m.Lltv, m.LastUpdate) + lo, hi := big.NewInt(742_000_000), big.NewInt(743_000_000) + if got.Cmp(lo) < 0 || got.Cmp(hi) > 0 { + t.Fatalf("RepaidAssetsForSeize = %s, want in [%s, %s]", got, lo, hi) + } +} + +func TestApplySeizeLiquidationOrdinary(t *testing.T) { + m := MarketState{ + TotalSupplyAssets: mustBig("2000000"), + TotalSupplyShares: mustBig("2000000"), + TotalBorrowAssets: mustBig("1000000"), + TotalBorrowShares: mustBig("1000000"), + Lltv: mustBig("500000000000000000"), + } + p := PositionState{BorrowShares: mustBig("500000"), Collateral: mustBig("1000000000000000000")} + seized := mustBig("10000000000000000") + price := mustBig("1000000000000000000000000") + + got, ok := ApplySeizeLiquidation(m, p, seized, price) + if !ok { + t.Fatal("ordinary liquidation should replay") + } + lif := LiquidationIncentiveFactor(m.Lltv) + wantRepaid := RepaidAssetsForSeizeAt(seized, price, lif, m.TotalBorrowAssets, m.TotalBorrowShares) + if got.RepaidAssets.Cmp(wantRepaid) != 0 { + t.Fatalf("repaidAssets = %s, want %s", got.RepaidAssets, wantRepaid) + } + if got.Position.Collateral.Cmp(new(big.Int).Sub(p.Collateral, seized)) != 0 { + t.Fatalf("collateral = %s, want %s", got.Position.Collateral, new(big.Int).Sub(p.Collateral, seized)) + } + if got.Market.TotalBorrowShares.Cmp(new(big.Int).Sub(m.TotalBorrowShares, got.RepaidShares)) != 0 { + t.Fatalf("totalBorrowShares = %s, want initial-repaidShares", got.Market.TotalBorrowShares) + } + if got.Market.TotalBorrowAssets.Cmp(new(big.Int).Sub(m.TotalBorrowAssets, got.RepaidAssets)) != 0 { + t.Fatalf("totalBorrowAssets = %s, want initial-repaidAssets", got.Market.TotalBorrowAssets) + } + if got.BadDebtAssets.Sign() != 0 || got.BadDebtShares.Sign() != 0 { + t.Fatalf("ordinary liquidation recorded bad debt assets=%s shares=%s", got.BadDebtAssets, got.BadDebtShares) + } +} + +func TestApplySeizeLiquidationBadDebt(t *testing.T) { + m := MarketState{ + TotalSupplyAssets: mustBig("2000000"), + TotalSupplyShares: mustBig("2000000"), + TotalBorrowAssets: mustBig("1000000"), + TotalBorrowShares: mustBig("1000000"), + Lltv: mustBig("500000000000000000"), + } + p := PositionState{BorrowShares: mustBig("500000"), Collateral: mustBig("100000000000000000")} + got, ok := ApplySeizeLiquidation(m, p, p.Collateral, mustBig("1000000000000000000000000")) + if !ok { + t.Fatal("bad-debt liquidation should replay") + } + if got.Position.Collateral.Sign() != 0 || got.Position.BorrowShares.Sign() != 0 { + t.Fatalf("borrower should be closed after bad debt, got collateral=%s borrowShares=%s", got.Position.Collateral, got.Position.BorrowShares) + } + if got.BadDebtShares.Sign() == 0 || got.BadDebtAssets.Sign() == 0 { + t.Fatalf("expected bad debt, got assets=%s shares=%s", got.BadDebtAssets, got.BadDebtShares) + } + wantBorrowShares := new(big.Int).Sub(new(big.Int).Sub(m.TotalBorrowShares, got.RepaidShares), got.BadDebtShares) + if got.Market.TotalBorrowShares.Cmp(wantBorrowShares) != 0 { + t.Fatalf("totalBorrowShares = %s, want %s", got.Market.TotalBorrowShares, wantBorrowShares) + } + wantSupplyAssets := new(big.Int).Sub(m.TotalSupplyAssets, got.BadDebtAssets) + if got.Market.TotalSupplyAssets.Cmp(wantSupplyAssets) != 0 { + t.Fatalf("totalSupplyAssets = %s, want %s", got.Market.TotalSupplyAssets, wantSupplyAssets) + } +} + +func TestApplySeizeLiquidationInvalidFailsClosed(t *testing.T) { + m := MarketState{ + TotalSupplyAssets: mustBig("2000000"), + TotalSupplyShares: mustBig("2000000"), + TotalBorrowAssets: mustBig("1000000"), + TotalBorrowShares: mustBig("1000000"), + Lltv: mustBig("500000000000000000"), + } + p := PositionState{BorrowShares: big.NewInt(1), Collateral: big.NewInt(1)} + if _, ok := ApplySeizeLiquidation(m, p, mustBig("1000000000000000000"), mustBig("10000000000000000000000000")); ok { + t.Fatal("over-seize/over-repay must fail closed") + } +} + +func mustBig(s string) *big.Int { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + panic("bad big int: " + s) + } + return n +} diff --git a/internal/parse/parse.go b/internal/parse/parse.go new file mode 100644 index 00000000..0a78b898 --- /dev/null +++ b/internal/parse/parse.go @@ -0,0 +1,116 @@ +// Package parse holds the pure parse/coerce primitives shared by the solvers' config parsing. +// It is protocol-agnostic framework code: it must not import any solver or protocol package. +package parse + +import ( + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" +) + +func Address(s, field string) (common.Address, error) { + if !common.IsHexAddress(s) { + return common.Address{}, errors.Errorf("%s: invalid address %q", field, s) + } + return common.HexToAddress(s), nil +} + +func NonZeroAddress(s, field string) (common.Address, error) { + addr, err := Address(s, field) + if err != nil { + return common.Address{}, err + } + if addr == (common.Address{}) { + return common.Address{}, errors.Errorf("%s: zero address (placeholder not replaced?)", field) + } + return addr, nil +} + +func Hash(s, field string) (common.Hash, error) { + // Decode (not just length-check) so a non-hex body fails closed instead of HexToHash silently + // zero-filling a typo'd id into the zero hash. + b, err := hexutil.Decode(s) + if err != nil || len(b) != 32 { + return common.Hash{}, errors.Errorf("%s: invalid 32-byte hex %q", field, s) + } + return common.BytesToHash(b), nil +} + +func Big(s, field string) (*big.Int, error) { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil, errors.Errorf("%s: invalid integer %q", field, s) + } + return n, nil +} + +// EthToWei converts a decimal ether string (e.g. "0.0005") to wei exactly (no float rounding): +// split on the point, right-pad the fraction to 18 digits, and combine. +func EthToWei(s, field string) (*big.Int, error) { + intPart, fracPart, hasDot := strings.Cut(s, ".") + if intPart == "" { + intPart = "0" + } + if len(fracPart) > 18 { + return nil, errors.Errorf("%s: more than 18 decimals: %q", field, s) + } + for len(fracPart) < 18 { + fracPart += "0" + } + combined := intPart + fracPart + if hasDot && fracPart == "" { + combined = intPart // "5." form + } + wei, ok := new(big.Int).SetString(combined, 10) + if !ok { + return nil, errors.Errorf("%s: invalid decimal %q", field, s) + } + if wei.Sign() < 0 { // an ETH amount is never negative; a "-…" would silently disable a floor/trigger + return nil, errors.Errorf("%s: must be >= 0, got %q", field, s) + } + return wei, nil +} + +// OrDefault returns v unless it is the zero value, in which case it returns fallback. +func OrDefault[T comparable](v, fallback T) T { + var zero T + if v == zero { + return fallback + } + return v +} + +// MsDuration converts a millisecond config field to a Duration: a nil pointer (field omitted) yields +// fallback, while a present value must be strictly positive — a set-but-non-positive interval is a +// misconfiguration and is rejected here rather than silently defaulted (mirrors the fail-closed +// duration handling in Duration). +func MsDuration(ms *int, fallback time.Duration, field string) (time.Duration, error) { + if ms == nil { + return fallback, nil + } + if *ms <= 0 { + return 0, errors.Errorf("%s: must be a positive duration in ms, got %d", field, *ms) + } + return time.Duration(*ms) * time.Millisecond, nil +} + +// Duration returns fallback when s is empty, but a present-but-invalid or non-positive value is +// an error rather than a silent fall back to the default — a typo'd interval should fail, not run at +// some surprising cadence. +func Duration(s string, fallback time.Duration, field string) (time.Duration, error) { + if s == "" { + return fallback, nil + } + d, err := time.ParseDuration(s) + if err != nil { + return 0, errors.Errorf("%s: invalid duration %q: %w", field, s, err) + } + if d <= 0 { + return 0, errors.Errorf("%s: duration must be positive, got %q", field, s) + } + return d, nil +} diff --git a/internal/parse/parse_test.go b/internal/parse/parse_test.go new file mode 100644 index 00000000..8a1b3d6e --- /dev/null +++ b/internal/parse/parse_test.go @@ -0,0 +1,215 @@ +package parse + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func TestAddress(t *testing.T) { + tests := []struct { + name string + in string + want common.Address + wantErr bool + }{ + {name: "valid", in: "0x1111111111111111111111111111111111111111", want: common.HexToAddress("0x1111111111111111111111111111111111111111")}, + {name: "zero", in: "0x0000000000000000000000000000000000000000", want: common.Address{}}, + {name: "no prefix", in: "1111111111111111111111111111111111111111", want: common.HexToAddress("0x1111111111111111111111111111111111111111")}, + {name: "too short", in: "0x1234", wantErr: true}, + {name: "empty", in: "", wantErr: true}, + {name: "not hex", in: "0xZZZZ111111111111111111111111111111111111", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Address(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("Address(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("Address(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestNonZeroAddress(t *testing.T) { + tests := []struct { + name string + in string + want common.Address + wantErr bool + }{ + {name: "valid", in: "0x2222222222222222222222222222222222222222", want: common.HexToAddress("0x2222222222222222222222222222222222222222")}, + {name: "zero address", in: "0x0000000000000000000000000000000000000000", wantErr: true}, + {name: "invalid", in: "0xnope", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NonZeroAddress(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("NonZeroAddress(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("NonZeroAddress(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestHash(t *testing.T) { + tests := []struct { + name string + in string + wantErr bool + }{ + {name: "valid", in: "0x000000000000000000000000000000000000000000000000000000000000beef"}, + {name: "no prefix", in: "000000000000000000000000000000000000000000000000000000000000beef", wantErr: true}, + {name: "too short", in: "0xbeef", wantErr: true}, + {name: "too long", in: "0x00000000000000000000000000000000000000000000000000000000000000beef", wantErr: true}, + {name: "empty", in: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Hash(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("Hash(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got != common.HexToHash(tt.in) { + t.Fatalf("Hash(%q) = %v, want %v", tt.in, got, common.HexToHash(tt.in)) + } + }) + } +} + +func TestBig(t *testing.T) { + tests := []struct { + name string + in string + want *big.Int + wantErr bool + }{ + {name: "positive", in: "12345", want: big.NewInt(12345)}, + {name: "zero", in: "0", want: big.NewInt(0)}, + {name: "negative", in: "-7", want: big.NewInt(-7)}, + {name: "not a number", in: "abc", wantErr: true}, + {name: "empty", in: "", wantErr: true}, + {name: "hex rejected", in: "0x10", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Big(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("Big(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got.Cmp(tt.want) != 0 { + t.Fatalf("Big(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestEthToWei(t *testing.T) { + mustBig := func(s string) *big.Int { + n, _ := new(big.Int).SetString(s, 10) + return n + } + tests := []struct { + name string + in string + want *big.Int + wantErr bool + }{ + {name: "fractional", in: "0.0005", want: mustBig("500000000000000")}, + {name: "whole", in: "1", want: mustBig("1000000000000000000")}, + {name: "zero", in: "0", want: big.NewInt(0)}, + {name: "trailing dot", in: "5.", want: mustBig("5000000000000000000")}, + {name: "leading dot", in: ".5", want: mustBig("500000000000000000")}, + {name: "18 decimals", in: "0.000000000000000001", want: big.NewInt(1)}, + {name: "more than 18 decimals", in: "0.0000000000000000001", wantErr: true}, + {name: "negative", in: "-1", wantErr: true}, + {name: "garbage", in: "abc", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := EthToWei(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("EthToWei(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got.Cmp(tt.want) != 0 { + t.Fatalf("EthToWei(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestOrDefault(t *testing.T) { + if got := OrDefault("", "fallback"); got != "fallback" { + t.Fatalf("OrDefault empty string = %q, want fallback", got) + } + if got := OrDefault("set", "fallback"); got != "set" { + t.Fatalf("OrDefault non-empty string = %q, want set", got) + } + if got := OrDefault(0, 42); got != 42 { + t.Fatalf("OrDefault zero int = %d, want 42", got) + } + if got := OrDefault(7, 42); got != 7 { + t.Fatalf("OrDefault non-zero int = %d, want 7", got) + } +} + +func TestMsDuration(t *testing.T) { + fallback := 3 * time.Second + ptr := func(i int) *int { return &i } + tests := []struct { + name string + in *int + want time.Duration + wantErr bool + }{ + {name: "nil uses fallback", in: nil, want: fallback}, + {name: "positive", in: ptr(1500), want: 1500 * time.Millisecond}, + {name: "zero rejected", in: ptr(0), wantErr: true}, + {name: "negative rejected", in: ptr(-5), wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MsDuration(tt.in, fallback, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("MsDuration err = %v, wantErr %v", err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("MsDuration = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDuration(t *testing.T) { + fallback := 10 * time.Second + tests := []struct { + name string + in string + want time.Duration + wantErr bool + }{ + {name: "empty uses fallback", in: "", want: fallback}, + {name: "valid", in: "2m", want: 2 * time.Minute}, + {name: "invalid", in: "notaduration", wantErr: true}, + {name: "zero rejected", in: "0s", wantErr: true}, + {name: "negative rejected", in: "-1s", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Duration(tt.in, fallback, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("Duration(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("Duration(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/solvers/bridgefacilitator/config.go b/internal/solvers/bridgefacilitator/config.go index c8825354..ba87d816 100644 --- a/internal/solvers/bridgefacilitator/config.go +++ b/internal/solvers/bridgefacilitator/config.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/common" "gopkg.in/yaml.v3" + "github.com/symbioticfi/vault-solver/internal/parse" "github.com/symbioticfi/vault-solver/internal/solver" ) @@ -94,20 +95,20 @@ func parseConfig(node yaml.Node) (*Config, error) { return nil, err } - discover, err := parseDuration(raw.Intervals.Discover, defaultDiscover, "intervals.discover") + discover, err := parse.Duration(raw.Intervals.Discover, defaultDiscover, "intervals.discover") if err != nil { return nil, err } - redeemPoll, err := parseDuration(raw.Intervals.RedeemPoll, defaultRedeemPoll, "intervals.redeemPoll") + redeemPoll, err := parse.Duration(raw.Intervals.RedeemPoll, defaultRedeemPoll, "intervals.redeemPoll") if err != nil { return nil, err } - reconcile, err := parseDuration(raw.Intervals.Reconcile, defaultReconcile, "intervals.reconcile") + reconcile, err := parse.Duration(raw.Intervals.Reconcile, defaultReconcile, "intervals.reconcile") if err != nil { return nil, err } - httpTimeout, err := parseDuration(raw.HTTPTimeout, defaultHTTPTimeout, "httpTimeout") + httpTimeout, err := parse.Duration(raw.HTTPTimeout, defaultHTTPTimeout, "httpTimeout") if err != nil { return nil, err } @@ -125,44 +126,9 @@ func parseConfig(node yaml.Node) (*Config, error) { func parseTarget(raw rawConfig) (Target, error) { // The zero address is rejected so an unreplaced placeholder fails at startup rather than being // registered as the 3F offer-address. - adapter, err := parseNonZeroAddress(raw.Adapter, "adapter") + adapter, err := parse.NonZeroAddress(raw.Adapter, "adapter") if err != nil { return Target{}, err } return Target{Adapter: adapter}, nil } - -func parseAddress(s, field string) (common.Address, error) { - if !common.IsHexAddress(s) { - return common.Address{}, errors.Errorf("%s: invalid address %q", field, s) - } - return common.HexToAddress(s), nil -} - -func parseNonZeroAddress(s, field string) (common.Address, error) { - addr, err := parseAddress(s, field) - if err != nil { - return common.Address{}, err - } - if addr == (common.Address{}) { - return common.Address{}, errors.Errorf("%s: zero address (placeholder not replaced?)", field) - } - return addr, nil -} - -// parseDuration returns fallback when s is empty, but a present-but-invalid or non-positive value is -// an error rather than a silent fall back to the default — a typo'd interval should fail, not run at -// some surprising cadence. -func parseDuration(s string, fallback time.Duration, field string) (time.Duration, error) { - if s == "" { - return fallback, nil - } - d, err := time.ParseDuration(s) - if err != nil { - return 0, errors.Errorf("%s: invalid duration %q: %w", field, s, err) - } - if d <= 0 { - return 0, errors.Errorf("%s: duration must be positive, got %q", field, s) - } - return d, nil -} diff --git a/internal/solvers/rfq/apitypes.go b/internal/solvers/rfq/apitypes.go index ecc0cfc4..6419301d 100644 --- a/internal/solvers/rfq/apitypes.go +++ b/internal/solvers/rfq/apitypes.go @@ -6,6 +6,8 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/parse" ) // quoteRequest is the backend → filler RFQ quote request (POST /quote). The validation tags drive @@ -80,11 +82,11 @@ func (q *quoteRequest) toStrategy(chainID int64) (*parsedQuote, error) { if !common.IsHexAddress(q.Swapper) { return nil, errors.Errorf("swapper: invalid address %q", q.Swapper) } - tokenIn, err := parseAddress(q.TokenIn, "tokenIn") + tokenIn, err := parse.Address(q.TokenIn, "tokenIn") if err != nil { return nil, err } - tokenOut, err := parseAddress(q.TokenOut, "tokenOut") + tokenOut, err := parse.Address(q.TokenOut, "tokenOut") if err != nil { return nil, err } @@ -118,11 +120,11 @@ func (q *quoteRequest) toStrategy(chainID int64) (*parsedQuote, error) { } func (v *quoteAdapter) parse(index int) (solverInventory, error) { - adapter, err := parseAddress(v.Adapter, idxField(index, "adapter")) + adapter, err := parse.Address(v.Adapter, idxField(index, "adapter")) if err != nil { return solverInventory{}, err } - asset, err := parseAddress(v.Asset, idxField(index, "asset")) + asset, err := parse.Address(v.Asset, idxField(index, "asset")) if err != nil { return solverInventory{}, err } diff --git a/internal/solvers/rfq/chainreader.go b/internal/solvers/rfq/chainreader.go index 91b59364..e89ade21 100644 --- a/internal/solvers/rfq/chainreader.go +++ b/internal/solvers/rfq/chainreader.go @@ -3,14 +3,13 @@ package rfq import ( "context" "math/big" - "sync" "github.com/ethereum/go-ethereum/common" "github.com/go-errors/errors" "github.com/go-logr/logr" "github.com/symbioticfi/vault-solver/api/bindings/erc4626" - "github.com/symbioticfi/vault-solver/api/bindings/rfq/adapter" + "github.com/symbioticfi/vault-solver/api/bindings/liquidlane/adapter" "github.com/symbioticfi/vault-solver/internal/chain" ) @@ -18,8 +17,8 @@ import ( // ABI change fails at compile time (see CLAUDE.md "Code generation"). var ( llAdapter = adapter.NewLiquidLaneAdapter() - // erc4626b serves both the vault's asset() and the asset token's decimals(): IERC4626 is an - // ERC-20, and a method's selector/return shape is fixed by the ABI regardless of target. + // erc4626b serves the vault's asset(): a method's selector/return shape is fixed by the ABI + // regardless of target. (Token decimals go through the shared chain.Decimals helper.) erc4626b = erc4626.NewIERC4626() ) @@ -28,18 +27,16 @@ var ( // (see resolveVaults), not re-read here. const readsPerAdapter = 3 -// reader performs the on-chain reads, batching via Multicall3. Token decimals are cached; the HTTP -// server serves quotes concurrently, so the cache is mutex-guarded. +// reader performs the on-chain reads, batching via Multicall3. Token decimals are resolved + cached by +// the shared chain.Decimals helper (its own mutex), so concurrent quote requests stay safe. type reader struct { chain *chain.Client log logr.Logger - - mu sync.Mutex - decimals map[common.Address]int + dec *chain.Decimals } func newReader(c *chain.Client, log logr.Logger) *reader { - return &reader{chain: c, log: log, decimals: make(map[common.Address]int)} + return &reader{chain: c, log: log, dec: chain.NewDecimals(c)} } // recoveryVault is one configured LiquidLane adapter plus the Vault and Asset derived from it. Config @@ -52,30 +49,10 @@ type recoveryVault struct { Asset common.Address } -// tokenDecimals returns the ERC-20 decimals for token, caching the result. +// tokenDecimals returns the ERC-20 decimals for token (cached). Delegates to the shared chain.Decimals +// so the quote + recovery paths reuse one cache instead of a per-reader copy. func (r *reader) tokenDecimals(ctx context.Context, token common.Address) (int, error) { - r.mu.Lock() - if d, ok := r.decimals[token]; ok { - r.mu.Unlock() - return d, nil - } - r.mu.Unlock() - - res, err := r.chain.Multicall(ctx, []chain.Call{{Target: token, Data: erc4626b.PackDecimals()}}) - if err != nil { - return 0, err - } - if len(res) != 1 || !res[0].Success { - return 0, errors.Errorf("erc20.decimals() reverted for %s", token) - } - d, err := erc4626b.UnpackDecimals(res[0].ReturnData) - if err != nil { - return 0, errors.Errorf("unpack decimals: %w", err) - } - r.mu.Lock() - r.decimals[token] = int(d) - r.mu.Unlock() - return int(d), nil + return r.dec.Get(ctx, token) } // amountsOut prices each distinct asset by calling its representative adapter's getAmountOut(tokenIn, diff --git a/internal/solvers/rfq/config.go b/internal/solvers/rfq/config.go index 47e5882e..2314436b 100644 --- a/internal/solvers/rfq/config.go +++ b/internal/solvers/rfq/config.go @@ -8,6 +8,7 @@ import ( "github.com/go-errors/errors" "gopkg.in/yaml.v3" + "github.com/symbioticfi/vault-solver/internal/parse" "github.com/symbioticfi/vault-solver/internal/solver" ) @@ -72,7 +73,7 @@ func parseConfig(node yaml.Node) (*Config, error) { if raw.BackendSharedSecretEnv == "" { return nil, errors.New("backendSharedSecretEnv is required") } - executor, err := parseAddress(raw.Executor, "executor") + executor, err := parse.Address(raw.Executor, "executor") if err != nil { return nil, err } @@ -80,7 +81,7 @@ func parseConfig(node yaml.Node) (*Config, error) { cfg := &Config{ BackendURL: raw.BackendURL, BackendSharedSecretEnv: raw.BackendSharedSecretEnv, - ListenAddr: orStr(raw.ListenAddr, defaultListenAddr), + ListenAddr: parse.OrDefault(raw.ListenAddr, defaultListenAddr), Executor: executor, PollInterval: defaultPollInterval, OrderLimit: defaultOrderLimit, @@ -94,14 +95,14 @@ func parseConfig(node yaml.Node) (*Config, error) { } // Reactor is optional (used by execution). Parse when present so a bad address fails fast. if raw.Reactor != "" { - if cfg.Reactor, err = parseAddress(raw.Reactor, "reactor"); err != nil { + if cfg.Reactor, err = parse.Address(raw.Reactor, "reactor"); err != nil { return nil, err } } for i, a := range raw.Adapters { // The zero address is rejected so a placeholder fails at startup rather than weakening the // whitelist. Vault + Asset are resolved on-chain at startup. - adapterAddr, verr := parseNonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") + adapterAddr, verr := parse.NonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") if verr != nil { return nil, verr } @@ -114,28 +115,3 @@ func parseConfig(node yaml.Node) (*Config, error) { } return cfg, nil } - -func parseAddress(s, field string) (common.Address, error) { - if !common.IsHexAddress(s) { - return common.Address{}, errors.Errorf("%s: invalid address %q", field, s) - } - return common.HexToAddress(s), nil -} - -func parseNonZeroAddress(s, field string) (common.Address, error) { - addr, err := parseAddress(s, field) - if err != nil { - return common.Address{}, err - } - if addr == (common.Address{}) { - return common.Address{}, errors.Errorf("%s: zero address (placeholder not replaced?)", field) - } - return addr, nil -} - -func orStr(v, fallback string) string { - if v == "" { - return fallback - } - return v -} diff --git a/internal/solvers/rfq/config_test.go b/internal/solvers/rfq/config_test.go index 48d8bed3..49bea7fa 100644 --- a/internal/solvers/rfq/config_test.go +++ b/internal/solvers/rfq/config_test.go @@ -8,7 +8,7 @@ import ( "gopkg.in/yaml.v3" ) -func parse(t *testing.T, body string) (*Config, error) { +func parseCfg(t *testing.T, body string) (*Config, error) { t.Helper() var doc yaml.Node if err := yaml.Unmarshal([]byte(body), &doc); err != nil { @@ -24,7 +24,7 @@ executor: "0x0000000000000000000000000000000000000010" ` func TestParseConfig_Defaults(t *testing.T) { - cfg, err := parse(t, minimalConfig) + cfg, err := parseCfg(t, minimalConfig) if err != nil { t.Fatalf("parseConfig: %v", err) } @@ -43,7 +43,7 @@ func TestParseConfig_Defaults(t *testing.T) { } func TestParseConfig_UnknownKeyRejected(t *testing.T) { - if _, err := parse(t, minimalConfig+"pollIntervalMs: 100\nordreLimit: 5\n"); err == nil { + if _, err := parseCfg(t, minimalConfig+"pollIntervalMs: 100\nordreLimit: 5\n"); err == nil { t.Fatal("expected a typo'd key to be rejected") } } @@ -63,7 +63,7 @@ adapters: } for name, tc := range cases { t.Run(name, func(t *testing.T) { - cfg, err := parse(t, minimalConfig+tc.yaml+"\n") + cfg, err := parseCfg(t, minimalConfig+tc.yaml+"\n") if err != nil { t.Fatalf("parseConfig: %v", err) } @@ -75,7 +75,7 @@ adapters: } func TestParseConfig_Overrides(t *testing.T) { - cfg, err := parse(t, minimalConfig+` + cfg, err := parseCfg(t, minimalConfig+` listenAddr: ":9000" pollIntervalMs: 1500 orderLimit: 5 @@ -94,7 +94,7 @@ reactor: "0x0000000000000000000000000000000000000030" } func TestParseConfig_Adapters(t *testing.T) { - cfg, err := parse(t, minimalConfig+` + cfg, err := parseCfg(t, minimalConfig+` adapters: - "0x0000000000000000000000000000000000000042" `) @@ -128,7 +128,7 @@ adapters: } for name, body := range cases { t.Run(name, func(t *testing.T) { - if _, err := parse(t, minimalConfig+body); err == nil { + if _, err := parseCfg(t, minimalConfig+body); err == nil { t.Fatalf("expected an error for %q", name) } }) @@ -156,7 +156,7 @@ adapterWhitelistEnabled: true } for name, body := range cases { t.Run(name, func(t *testing.T) { - if _, err := parse(t, body); err == nil { + if _, err := parseCfg(t, body); err == nil { t.Fatalf("expected an error for %q", name) } }) diff --git a/internal/solvers/rfq/store.go b/internal/solvers/rfq/store.go index 2d2c1f12..3f4c170e 100644 --- a/internal/solvers/rfq/store.go +++ b/internal/solvers/rfq/store.go @@ -5,6 +5,8 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/parse" ) // orderStatus is the local order lifecycle. queued → submitting → submitted → {filled|expired|failed}. @@ -129,7 +131,7 @@ func (s *store) upsertQueued(in queuedOrder) { rec.Status = statusQueued rec.LastError = "" } - rec.QuoteID = orStr(in.QuoteID, rec.QuoteID) + rec.QuoteID = parse.OrDefault(in.QuoteID, rec.QuoteID) rec.UpdatedAt = now } diff --git a/internal/solvers/rfq/strategy.go b/internal/solvers/rfq/strategy.go index 9ebc7407..824930f9 100644 --- a/internal/solvers/rfq/strategy.go +++ b/internal/solvers/rfq/strategy.go @@ -6,10 +6,12 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/chain" ) // rateScale is the adapter's fixed-point rate scale (1e18). -var rateScale = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) +var rateScale = chain.Exp10(18) // solverInventory is one candidate adapter leg, taken from the backend quote request's snapshot // (the filler does not re-read maxAssets/maxRate/decimals on-chain in the quote path). "adapter" is @@ -238,9 +240,11 @@ func dedupeByAdapter(legs []eligibleLeg) []eligibleLeg { /* ───────── fixed-point rate math ───────── */ -func pow10(n int) *big.Int { return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil) } +func pow10(n int) *big.Int { return chain.Exp10(n) } -// amountOutForRate = amountIn * rate * 10^assetDec / (RATE_SCALE * 10^tokenInDec). +// amountOutForRate = amountIn * rate * 10^assetDec / (RATE_SCALE * 10^tokenInDec). Replicates the +// LiquidLane adapter's getAmountOut; the OEV solver has the same formula inline (redstoneoev/sizing.go +// adapterOut) — keep both in sync (deliberately not unified into one 5-arg helper, see that site). func amountOutForRate(amountIn, rate *big.Int, tokenInDec, assetDec int) *big.Int { num := new(big.Int).Mul(amountIn, rate) num.Mul(num, pow10(assetDec)) From e09c4a0492e8499bf8f29965d8a419cc61e96578 Mon Sep 17 00:00:00 2001 From: alrxy Date: Fri, 26 Jun 2026 18:59:02 +0700 Subject: [PATCH 08/50] feat: add RedStone OEV solver --- cmd/vault-solver/root.go | 1 + internal/solvers/redstoneoev/breaker.go | 62 ++ internal/solvers/redstoneoev/bundle.go | 395 ++++++++++ .../solvers/redstoneoev/callbackevents.go | 83 ++ internal/solvers/redstoneoev/candidates.go | 110 +++ internal/solvers/redstoneoev/chainreader.go | 633 +++++++++++++++ internal/solvers/redstoneoev/config.go | 262 +++++++ internal/solvers/redstoneoev/eip191.go | 69 ++ internal/solvers/redstoneoev/epoch.go | 14 + internal/solvers/redstoneoev/fillerauth.go | 61 ++ internal/solvers/redstoneoev/gaspredictor.go | 238 ++++++ internal/solvers/redstoneoev/metrics.go | 113 +++ internal/solvers/redstoneoev/monitor.go | 379 +++++++++ internal/solvers/redstoneoev/morphoapi.go | 334 ++++++++ internal/solvers/redstoneoev/noncestore.go | 34 + internal/solvers/redstoneoev/operationdata.go | 160 ++++ internal/solvers/redstoneoev/rate.go | 55 ++ internal/solvers/redstoneoev/reservations.go | 147 ++++ internal/solvers/redstoneoev/sizing.go | 174 +++++ internal/solvers/redstoneoev/solver.go | 729 ++++++++++++++++++ internal/solvers/redstoneoev/testflags.go | 43 ++ internal/solvers/redstoneoev/testmonitor.go | 307 ++++++++ internal/solvers/redstoneoev/wsclient.go | 246 ++++++ internal/solvers/redstoneoev/wsmessages.go | 143 ++++ 24 files changed, 4792 insertions(+) create mode 100644 internal/solvers/redstoneoev/breaker.go create mode 100644 internal/solvers/redstoneoev/bundle.go create mode 100644 internal/solvers/redstoneoev/callbackevents.go create mode 100644 internal/solvers/redstoneoev/candidates.go create mode 100644 internal/solvers/redstoneoev/chainreader.go create mode 100644 internal/solvers/redstoneoev/config.go create mode 100644 internal/solvers/redstoneoev/eip191.go create mode 100644 internal/solvers/redstoneoev/epoch.go create mode 100644 internal/solvers/redstoneoev/fillerauth.go create mode 100644 internal/solvers/redstoneoev/gaspredictor.go create mode 100644 internal/solvers/redstoneoev/metrics.go create mode 100644 internal/solvers/redstoneoev/monitor.go create mode 100644 internal/solvers/redstoneoev/morphoapi.go create mode 100644 internal/solvers/redstoneoev/noncestore.go create mode 100644 internal/solvers/redstoneoev/operationdata.go create mode 100644 internal/solvers/redstoneoev/rate.go create mode 100644 internal/solvers/redstoneoev/reservations.go create mode 100644 internal/solvers/redstoneoev/sizing.go create mode 100644 internal/solvers/redstoneoev/solver.go create mode 100644 internal/solvers/redstoneoev/testflags.go create mode 100644 internal/solvers/redstoneoev/testmonitor.go create mode 100644 internal/solvers/redstoneoev/wsclient.go create mode 100644 internal/solvers/redstoneoev/wsmessages.go diff --git a/cmd/vault-solver/root.go b/cmd/vault-solver/root.go index 65dfab7d..6bdcd7b0 100644 --- a/cmd/vault-solver/root.go +++ b/cmd/vault-solver/root.go @@ -6,6 +6,7 @@ import ( // Solver implementations self-register via init(); these blank imports are the only references to // concrete solvers. Adding another solver is an import here plus a config switch. _ "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator" + _ "github.com/symbioticfi/vault-solver/internal/solvers/redstoneoev" _ "github.com/symbioticfi/vault-solver/internal/solvers/rfq" ) diff --git a/internal/solvers/redstoneoev/breaker.go b/internal/solvers/redstoneoev/breaker.go new file mode 100644 index 00000000..01abbe50 --- /dev/null +++ b/internal/solvers/redstoneoev/breaker.go @@ -0,0 +1,62 @@ +package redstoneoev + +import ( + "sync" + "time" +) + +// breaker halts bidding when RedStone blacklists our key, or when too many liquidations fail in a +// rolling window (a revert storm bleeds gas + nonce and risks blacklisting — §6.2). Safe for +// concurrent use; `now` is injected so it's testable. +type breaker struct { + mu sync.Mutex + blacklisted bool + failures []time.Time + maxFailures int + window time.Duration +} + +func newBreaker(maxFailures int, window time.Duration) *breaker { + return &breaker{maxFailures: maxFailures, window: window} +} + +// blacklist permanently trips the breaker (until restart). Called on the `blacklisted` WS frame. +func (b *breaker) blacklist() { + b.mu.Lock() + b.blacklisted = true + b.mu.Unlock() +} + +// recordFailure logs a failed settlement and prunes the window. +func (b *breaker) recordFailure(now time.Time) { + b.mu.Lock() + defer b.mu.Unlock() + b.failures = append(b.failures, now) + b.prune(now) +} + +// tripped reports whether bidding must halt, with a reason. +func (b *breaker) tripped(now time.Time) (bool, string) { + b.mu.Lock() + defer b.mu.Unlock() + if b.blacklisted { + return true, "api key blacklisted" + } + b.prune(now) + if b.maxFailures > 0 && len(b.failures) >= b.maxFailures { + return true, "failed-liquidation rate-limit" + } + return false, "" +} + +// prune drops failures older than the window. Caller holds the lock. +func (b *breaker) prune(now time.Time) { + cutoff := now.Add(-b.window) + keep := b.failures[:0] + for _, t := range b.failures { + if t.After(cutoff) { + keep = append(keep, t) + } + } + b.failures = keep +} diff --git a/internal/solvers/redstoneoev/bundle.go b/internal/solvers/redstoneoev/bundle.go new file mode 100644 index 00000000..5cd606b5 --- /dev/null +++ b/internal/solvers/redstoneoev/bundle.go @@ -0,0 +1,395 @@ +package redstoneoev + +// bundle.go holds the leg-selection engine that turns scored legs into one priced solve. + +import ( + "cmp" + "maps" + "math/big" + "slices" + "strings" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +const ( + netBundleBeamWidth = 64 + maxBundleSearchCandidates = 512 +) + +// scoredLeg is a liquidatable, sized leg paired with its expected profit, in the single loan token's base +// units (one adapter ⇒ one loan token, so there is nothing to group by). +type scoredLeg struct { + leg LiquidationLeg + profit *big.Int // loan-token base units + collateral common.Address // seized collateral; legs sharing it share the adapter's getMaxAssets pool + maxAssets *big.Int // that collateral's getMaxAssets budget (loan units; nil ⇒ uncapped liquidity) + source evalItem + replay bool +} + +// chosenBundle is the set of legs selected for one solve. Single-token by design: the on-chain callback +// runs every leg against its one immutable LiquidLaneAdapter and a single loan token. +type chosenBundle struct { + legs []LiquidationLeg + collaterals []common.Address // seized collateral per leg, parallel to legs; used by the gas predictor + borrowers []string // lowercased borrower addresses, parallel to legs (the solve's borrowers field) + grossLoan *big.Int // Σ leg profit in the loan token's units +} + +type pricedBundle struct { + gas gasPrediction + gasNative *big.Int + bidNative *big.Int + minBundleProfitLoan *big.Int + callbackLegs []LiquidationLeg +} + +// selectBundle is the gross-profit fallback for dry-run/no-rate paths. Live bidding uses selectNetBundle. +// +// Legs sharing collateral also share the adapter's getMaxAssets pool, so selection caps cumulative swapOut +// per collateral to avoid InsufficientAllocate at settlement. +func (s *Solver) selectBundle(scored []scoredLeg) (chosenBundle, string) { + return s.selectBundleWithGas(scored, nil, 0, defaultPriceUpdateFeeds) +} + +func (s *Solver) selectBundleWithGas(scored []scoredLeg, gasState *gasPredictorState, gasLimit uint64, feedCount int) (chosenBundle, string) { + if len(scored) == 0 { + return chosenBundle{}, skipNoLegs + } + best, ok := s.searchBundle(scored, gasState, gasLimit, feedCount, func(b chosenBundle) *big.Int { + return new(big.Int).Set(b.grossLoan) + }) + if !ok { + return chosenBundle{}, skipNoLegs + } + return best.bundle, "" +} + +// selectNetBundle maximizes bounded after-cost net while preserving deterministic tie-breaks and the shared +// collateral budget. A lower-gross subset can beat a gross-best subset once gas and the bid are priced in. +func (s *Solver) selectNetBundle(scored []scoredLeg, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int, gasLimit uint64, feedCount int) (chosenBundle, string) { + if len(scored) == 0 { + return chosenBundle{}, skipNoLegs + } + if rate == nil || rate.Sign() <= 0 { + return s.selectBundleWithGas(scored, gasState, gasLimit, feedCount) + } + best, ok := s.searchBundle(scored, gasState, gasLimit, feedCount, func(b chosenBundle) *big.Int { + return s.bundleNetNativeForFeeds(b, rate, gasState, gasPrice, feedCount) + }) + if !ok { + return chosenBundle{}, skipGasUnprofitable + } + bidNative := s.bundleBidNative(best.bundle, rate) + minNative := s.minBundleProfitNative(bidNative) + bestNet := s.bundleNetNativeForFeeds(best.bundle, rate, gasState, gasPrice, feedCount) + if bestNet.Cmp(minNative) < 0 { + return best.bundle, skipGasUnprofitable + } + return best.bundle, "" +} + +type bundleSearchState struct { + bundle chosenBundle + consumed map[common.Address]*big.Int + markets map[common.Hash]bundleMarketState + used map[int]bool + score *big.Int +} + +type bundleMarketState struct { + info MarketInfo + positions map[common.Address]morpho.PositionState +} + +type replayedScoredLeg struct { + leg scoredLeg + marketID common.Hash + market bundleMarketState +} + +func (s *Solver) searchBundle(scored []scoredLeg, gasState *gasPredictorState, gasLimit uint64, feedCount int, scoreFn func(chosenBundle) *big.Int) (bundleSearchState, bool) { + maxDepth := bundleSearchDepth(gasLimit, feedCount) + if maxDepth == 0 { + return bundleSearchState{}, false + } + group := bundleSearchCandidates(scored) + start := bundleSearchState{ + bundle: chosenBundle{grossLoan: new(big.Int)}, + consumed: make(map[common.Address]*big.Int), + markets: make(map[common.Hash]bundleMarketState), + used: make(map[int]bool), + score: new(big.Int), + } + beam := []bundleSearchState{start} + best := start + for depth := 0; depth < maxDepth && depth < len(group); depth++ { + nextBeam := make([]bundleSearchState, 0, min(len(group), netBundleBeamWidth)) + for _, state := range beam { + for i, sl := range group { + if state.used[i] { + continue + } + trial, ok := s.extendBundleState(state, sl, i) + if !ok { + continue + } + if !bundleFitsGasLimit(trial.bundle, gasState, gasLimit, feedCount) { + continue + } + trial.score = scoreFn(trial.bundle) + nextBeam = append(nextBeam, trial) + } + } + if len(nextBeam) == 0 { + break + } + slices.SortStableFunc(nextBeam, func(a, b bundleSearchState) int { + return b.score.Cmp(a.score) + }) + if len(nextBeam) > netBundleBeamWidth { + nextBeam = nextBeam[:netBundleBeamWidth] + } + if len(best.bundle.legs) == 0 || nextBeam[0].score.Cmp(best.score) > 0 { + best = nextBeam[0] + } + beam = nextBeam + } + return best, len(best.bundle.legs) > 0 +} + +func bundleSearchCandidates(scored []scoredLeg) []scoredLeg { + group := sortedScoredLegs(scored) + if len(group) <= maxBundleSearchCandidates { + return group + } + return group[:maxBundleSearchCandidates] +} + +func bundleSearchDepth(gasLimit uint64, feedCount int) int { + usable := usableBundleGasLimit(gasLimit) + fixed := saturatingAddUint64(fixedGasUnits(feedCount), gasFirstAcquireLeg) + if usable <= fixed { + return 0 + } + return 1 + int((usable-fixed)/gasAdditionalAcquireLeg) +} + +func (s *Solver) extendBundleState(state bundleSearchState, sl scoredLeg, idx int) (bundleSearchState, bool) { + next, ok := s.replayScoredLeg(sl, state.markets) + if !ok || !fitsCollateralBudget(state.consumed, next.leg) { + return bundleSearchState{}, false + } + trial := bundleSearchState{ + bundle: cloneBundleWithLeg(state.bundle, next.leg), + consumed: cloneCollateralBudget(state.consumed), + markets: cloneBundleMarkets(state.markets), + used: cloneUsed(state.used), + } + trial.used[idx] = true + if next.marketID != (common.Hash{}) { + trial.markets[next.marketID] = next.market + } + commitCollateralBudget(trial.consumed, next.leg) + return trial, true +} + +func (s *Solver) replayScoredLeg(sl scoredLeg, markets map[common.Hash]bundleMarketState) (replayedScoredLeg, bool) { + if !sl.replay { + return replayedScoredLeg{leg: sl}, true + } + id := sl.source.cand.MarketID + if id == (common.Hash{}) { + return replayedScoredLeg{}, false + } + ms, ok := markets[id] + if !ok { + ms = bundleMarketState{info: cloneMarketInfo(sl.source.cand.Market), positions: make(map[common.Address]morpho.PositionState)} + } + pos, ok := ms.positions[sl.source.cand.Borrower] + if !ok { + pos = clonePositionState(sl.source.cand.Position) + } + cand := sl.source.cand + cand.Market = ms.info + cand.Position = pos + leg, profit, ok := sizeLeg(cand, sl.source.price, sl.source.quote, ms.info.State.TotalBorrowAssets, s.cfg.Sizing) + if !ok { + return replayedScoredLeg{}, false + } + replay, ok := morpho.ApplySeizeLiquidation(ms.info.State, pos, leg.MaxSeizeAssets, sl.source.price) + if !ok { + return replayedScoredLeg{}, false + } + nextMarket := cloneBundleMarketState(ms) + nextMarket.info.State = replay.Market + nextMarket.positions[cand.Borrower] = replay.Position + nextLeg := sl + nextLeg.leg = leg + nextLeg.profit = profit + nextLeg.collateral = cand.Market.Params.CollateralToken + nextLeg.maxAssets = sl.source.quote.MaxAssets + return replayedScoredLeg{leg: nextLeg, marketID: id, market: nextMarket}, true +} + +func sortedScoredLegs(scored []scoredLeg) []scoredLeg { + group := slices.Clone(scored) + slices.SortFunc(group, func(a, b scoredLeg) int { + return cmp.Or( + b.profit.Cmp(a.profit), // higher gross loan profit first + a.leg.MarketId.Cmp(b.leg.MarketId), // then (marketId, borrower) — unique, deterministic + a.leg.Borrower.Cmp(b.leg.Borrower), + ) + }) + return group +} + +func fitsCollateralBudget(consumed map[common.Address]*big.Int, sl scoredLeg) bool { + if sl.maxAssets == nil || sl.maxAssets.Sign() <= 0 { + return true + } + next := new(big.Int).Add(orZero(consumed[sl.collateral]), sl.leg.SwapAmountOut) + return next.Cmp(sl.maxAssets) <= 0 +} + +func commitCollateralBudget(consumed map[common.Address]*big.Int, sl scoredLeg) { + if sl.maxAssets == nil || sl.maxAssets.Sign() <= 0 { + return + } + consumed[sl.collateral] = new(big.Int).Add(orZero(consumed[sl.collateral]), sl.leg.SwapAmountOut) +} + +func cloneCollateralBudget(in map[common.Address]*big.Int) map[common.Address]*big.Int { + out := make(map[common.Address]*big.Int, len(in)) + for collateral, amount := range in { + out[collateral] = orZero(amount) + } + return out +} + +func cloneBundleMarkets(in map[common.Hash]bundleMarketState) map[common.Hash]bundleMarketState { + out := make(map[common.Hash]bundleMarketState, len(in)) + for id, state := range in { + out[id] = cloneBundleMarketState(state) + } + return out +} + +func cloneBundleMarketState(in bundleMarketState) bundleMarketState { + out := bundleMarketState{info: cloneMarketInfo(in.info), positions: make(map[common.Address]morpho.PositionState, len(in.positions))} + for borrower, position := range in.positions { + out.positions[borrower] = clonePositionState(position) + } + return out +} + +func cloneUsed(in map[int]bool) map[int]bool { + out := make(map[int]bool, len(in)) + maps.Copy(out, in) + return out +} + +func cloneMarketInfo(in MarketInfo) MarketInfo { + in.State = cloneMarketState(in.State) + return in +} + +func cloneMarketState(in morpho.MarketState) morpho.MarketState { + return morpho.MarketState{ + TotalSupplyAssets: cloneBig(in.TotalSupplyAssets), + TotalSupplyShares: cloneBig(in.TotalSupplyShares), + TotalBorrowAssets: cloneBig(in.TotalBorrowAssets), + TotalBorrowShares: cloneBig(in.TotalBorrowShares), + LastUpdate: in.LastUpdate, + Fee: cloneBig(in.Fee), + Lltv: cloneBig(in.Lltv), + BorrowRatePerSec: cloneBig(in.BorrowRatePerSec), + } +} + +func clonePositionState(in morpho.PositionState) morpho.PositionState { + return morpho.PositionState{BorrowShares: cloneBig(in.BorrowShares), Collateral: cloneBig(in.Collateral)} +} + +func appendScoredLeg(b *chosenBundle, sl scoredLeg) { + b.legs = append(b.legs, sl.leg) + b.collaterals = append(b.collaterals, sl.collateral) + b.borrowers = append(b.borrowers, strings.ToLower(sl.leg.Borrower.Hex())) + b.grossLoan.Add(b.grossLoan, sl.profit) +} + +func cloneBundleWithLeg(b chosenBundle, sl scoredLeg) chosenBundle { + out := chosenBundle{ + legs: slices.Clone(b.legs), + collaterals: slices.Clone(b.collaterals), + borrowers: slices.Clone(b.borrowers), + grossLoan: new(big.Int).Set(b.grossLoan), + } + appendScoredLeg(&out, sl) + return out +} + +func (s *Solver) bundleNetNative(b chosenBundle, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int) *big.Int { + return s.bundleNetNativeForFeeds(b, rate, gasState, gasPrice, defaultPriceUpdateFeeds) +} + +func (s *Solver) bundleNetNativeForFeeds(b chosenBundle, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int, feedCount int) *big.Int { + grossNative := loanToNative(b.grossLoan, rate) + gasUnits := gasPredictionForBundleFeeds(b, gasState, feedCount).Units + gasNative := gasCostNative(gasUnits, gasPrice) + grossNative.Sub(grossNative, gasNative) + return grossNative.Sub(grossNative, s.bundleBidNative(b, rate)) +} + +func (s *Solver) bundleBidNative(b chosenBundle, rate *big.Int) *big.Int { + minimal := orZero(s.cfg.BidWei) + if s.cfg.TotalBundleProfitBps <= 0 { + return new(big.Int).Set(minimal) + } + share := ceilMulDiv(loanToNative(b.grossLoan, rate), big.NewInt(int64(s.cfg.TotalBundleProfitBps)), big.NewInt(10_000)) + if share.Cmp(minimal) < 0 { + return new(big.Int).Set(minimal) + } + return share +} + +func (s *Solver) minBundleProfitNative(bidNative *big.Int) *big.Int { + if s.cfg.MinBundleProfitBidBps <= 0 { + return new(big.Int) + } + return ceilMulDiv(orZero(bidNative), big.NewInt(int64(s.cfg.MinBundleProfitBidBps)), big.NewInt(10_000)) +} + +func (s *Solver) minBundleProfitLoan(b chosenBundle, rate *big.Int, gas gasPrediction, gasPrice *big.Int) *big.Int { + bidNative := s.bundleBidNative(b, rate) + requiredNative := gasCostNative(gas.Units, gasPrice) + requiredNative.Add(requiredNative, bidNative) + requiredNative.Add(requiredNative, s.minBundleProfitNative(bidNative)) + return nativeToLoan(requiredNative, rate) +} + +func (s *Solver) priceBundle(b chosenBundle, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int, feedCount int) pricedBundle { + gas := gasPredictionForBundleFeeds(b, gasState, feedCount) + return pricedBundle{ + gas: gas, + gasNative: gasCostNative(gas.Units, gasPrice), + bidNative: s.bundleBidNative(b, rate), + minBundleProfitLoan: s.minBundleProfitLoan(b, rate, gas, gasPrice), + callbackLegs: legsWithProfitFloors(b.legs, gas, gasPrice, rate), + } +} + +func ceilMulDiv(x, y, denom *big.Int) *big.Int { + if x == nil || y == nil || denom == nil || x.Sign() <= 0 || y.Sign() <= 0 || denom.Sign() <= 0 { + return new(big.Int) + } + num := new(big.Int).Mul(x, y) + q, r := new(big.Int).QuoRem(num, denom, new(big.Int)) + if r.Sign() > 0 { + q.Add(q, big.NewInt(1)) + } + return q +} diff --git a/internal/solvers/redstoneoev/callbackevents.go b/internal/solvers/redstoneoev/callbackevents.go new file mode 100644 index 00000000..af848e9d --- /dev/null +++ b/internal/solvers/redstoneoev/callbackevents.go @@ -0,0 +1,83 @@ +package redstoneoev + +import ( + "encoding/hex" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/go-logr/logr" +) + +func logCallbackEvents(log logr.Logger, callback common.Address, receipt *types.Receipt) { + for _, lg := range receipt.Logs { + if lg.Address != callback || len(lg.Topics) == 0 { + continue + } + if ev, err := callbackB.UnpackLegResultEvent(lg); err == nil { + fields := legResultCode(ev.Code) + log.Info("callback leg result", + "auctionKey", common.BytesToHash(ev.AuctionKey[:]).Hex(), + "market", common.BytesToHash(ev.MarketId[:]).Hex(), + "borrower", ev.Borrower.Hex(), + "index", fields.index, "status", legStatusLabel(fields.status), "reason", legReasonLabel(fields.reason), + "selector", fields.selector, "seizedAssets", ev.SeizedAssets, "repaidAssets", ev.RepaidAssets, + "profitLoan", ev.ProfitLoan) + continue + } + if ev, err := callbackB.UnpackPayBidResultEvent(lg); err == nil { + log.Info("callback paybid result", + "auctionKey", common.BytesToHash(ev.AuctionKey[:]).Hex(), + "bidAmount", ev.BidAmount, "paid", ev.Paid) + } + } +} + +type legResultFields struct { + index uint64 + status uint8 + reason uint8 + selector string +} + +func legResultCode(code *big.Int) legResultFields { + if code == nil { + return legResultFields{} + } + low := code.Uint64() + out := legResultFields{ + index: (low >> 16) & 0xffffffffffff, + status: uint8(low >> 8), + reason: uint8(low), + } + sel := new(big.Int).Rsh(new(big.Int).Set(code), 224) + if sel.Sign() == 0 { + return out + } + buf := make([]byte, 4) + sel.FillBytes(buf) + out.selector = "0x" + hex.EncodeToString(buf) + return out +} + +func legStatusLabel(status uint8) string { + switch status { + case 1: + return "success" + case 3: + return "reverted" + default: + return gasRouteUnknownLabel + } +} + +func legReasonLabel(reason uint8) string { + switch reason { + case 0: + return "none" + case 7: + return "morpho_revert" + default: + return gasRouteUnknownLabel + } +} diff --git a/internal/solvers/redstoneoev/candidates.go b/internal/solvers/redstoneoev/candidates.go new file mode 100644 index 00000000..0a6b36bf --- /dev/null +++ b/internal/solvers/redstoneoev/candidates.go @@ -0,0 +1,110 @@ +package redstoneoev + +import ( + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +type evalItem struct { + cand Candidate + price *big.Int + quote AdapterQuote + accrued *big.Int // totalBorrowAssets accrued to nowTs for cand's market +} + +type priceLookup func(id common.Hash, info MarketInfo) *big.Int + +func candidatesFromAuction(log logr.Logger, snap *snapshot, auction AuctionMessage, nowTs uint64) []evalItem { + frame := auctionPrices(log, auction) + return candidatesFromSnapshot(snap, nowTs, func(_ common.Hash, info MarketInfo) *big.Int { + return auctionPriceForMarket(frame, info) + }) +} + +func candidatesFromCachedPrices(snap *snapshot, nowTs uint64) []evalItem { + return candidatesFromSnapshot(snap, nowTs, func(id common.Hash, _ MarketInfo) *big.Int { + return snap.prices[id] + }) +} + +func auctionPriceForMarket(frame map[common.Address]*big.Int, info MarketInfo) *big.Int { + oracle := info.Params.Oracle + if oracle == (common.Address{}) { + return nil + } + return frame[oracle] +} + +func candidatesFromSnapshot(snap *snapshot, nowTs uint64, price priceLookup) []evalItem { + if snap == nil { + return nil + } + var out []evalItem + for id, info := range snap.markets { + pos := snap.positions[id] + if len(pos) == 0 { + continue // no tracked positions here — skip before the price/quote/accrual work + } + px := price(id, info) + if px == nil { + continue // no settlement price for this market's oracle + } + quote, ok := snap.quotes[id] + if !ok { + continue // adapter doesn't serve this market (or can't price it) -> can't size an exit + } + accruedState := morpho.AccruedMarketState(info.State, nowTs) + info.State = accruedState + for b, p := range pos { + out = append(out, evalItem{ + cand: Candidate{MarketID: id, Borrower: b, Market: info, Position: p}, + price: px, + quote: quote, + accrued: accruedState.TotalBorrowAssets, + }) + } + } + return out +} + +func auctionPrices(log logr.Logger, a AuctionMessage) map[common.Address]*big.Int { + out := make(map[common.Address]*big.Int, len(a.Payload.Prices)) + for k, v := range a.Payload.Prices { + if !common.IsHexAddress(k) { + log.V(1).Info("dropping auction price with invalid oracle address", "oracle", k) + continue + } + n, ok := new(big.Int).SetString(v, 10) + if !ok || n.Sign() <= 0 { + log.V(1).Info("dropping unparseable auction price", "oracle", k, "value", v) + continue + } + out[common.HexToAddress(k)] = n + } + return out +} + +// scoredLegs is I/O-free: it reads only the monitor snapshot and sizes one leg per liquidatable candidate. +func (s *Solver) scoredLegs(a AuctionMessage, now time.Time) []scoredLeg { + nowTs := clampTsAt(a.Timestamp, now) + cands := s.mon.candidates(a, nowTs) + out := make([]scoredLeg, 0, len(cands)) + for _, it := range cands { + if leg, profit, ok := sizeLeg(it.cand, it.price, it.quote, it.accrued, s.cfg.Sizing); ok { + out = append(out, scoredLeg{ + leg: leg, + profit: profit, + collateral: it.cand.Market.Params.CollateralToken, + maxAssets: it.quote.MaxAssets, // legs sharing this collateral share its getMaxAssets budget + source: it, + replay: true, + }) + } + } + return out +} diff --git a/internal/solvers/redstoneoev/chainreader.go b/internal/solvers/redstoneoev/chainreader.go new file mode 100644 index 00000000..83cf6153 --- /dev/null +++ b/internal/solvers/redstoneoev/chainreader.go @@ -0,0 +1,633 @@ +package redstoneoev + +import ( + "context" + "maps" + "math/big" + "slices" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/api/bindings/erc4626" + "github.com/symbioticfi/vault-solver/api/bindings/liquidlane/adapter" + "github.com/symbioticfi/vault-solver/api/bindings/oev/aggregator" + "github.com/symbioticfi/vault-solver/api/bindings/oev/callback" + "github.com/symbioticfi/vault-solver/api/bindings/oev/executor" + morphobinding "github.com/symbioticfi/vault-solver/api/bindings/oev/morpho" + "github.com/symbioticfi/vault-solver/api/bindings/oev/oracle" + "github.com/symbioticfi/vault-solver/api/bindings/vaultv2" + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// Contract binding instances (abigen --v2): typed Pack/Unpack helpers for the Multicall3 sub-calls below, +// driven the same way as the rfq reader, so an ABI change fails at compile time. The LiquidLane adapter is +// a neutral group driven by both rfq and redstone-oev; the ERC-4626 vault binding covers the ERC-20 reads +// (asset(), balanceOf()) — IERC4626 is an ERC-20. +var ( + morphoB = morphobinding.NewMorpho() + executorB = executor.NewRedStoneExecutor() + aggregatorB = aggregator.NewAggregatorV3() + callbackB = callback.NewSymbioticOevSolver() + oracleB = oracle.NewMorphoOracle() + + llAdapter = adapter.NewLiquidLaneAdapter() + erc4626b = erc4626.NewIERC4626() // asset() + balanceOf() (IERC4626 is an ERC-20) + vaultV2B = vaultv2.NewIVaultV2() +) + +// abiMarketParams is Morpho's MarketParams tuple (loanToken, collateralToken, oracle, irm, lltv). +type abiMarketParams struct { + LoanToken common.Address + CollateralToken common.Address + Oracle common.Address + Irm common.Address + Lltv *big.Int +} + +/* ───────── decoders ───────── */ + +// decodeMarketParams decodes Morpho idToMarketParams(id) into the params tuple. +func decodeMarketParams(data []byte) (abiMarketParams, error) { + out, err := morphoB.UnpackIdToMarketParams(data) + if err != nil { + return abiMarketParams{}, errors.Errorf("decode marketParams: %w", err) + } + if out.Lltv == nil { + return abiMarketParams{}, errors.New("decode marketParams: lltv nil") + } + return abiMarketParams{ + LoanToken: out.LoanToken, CollateralToken: out.CollateralToken, + Oracle: out.Oracle, Irm: out.Irm, Lltv: out.Lltv, + }, nil +} + +func decodeLatestRoundData(data []byte) (answer, updatedAt *big.Int, err error) { + out, e := aggregatorB.UnpackLatestRoundData(data) + if e != nil { + return nil, nil, errors.Errorf("decode latestRoundData: %w", e) + } + if out.Answer == nil || out.UpdatedAt == nil { + return nil, nil, errors.New("decode latestRoundData: nil field") + } + return out.Answer, out.UpdatedAt, nil +} + +func decodeDecimals(data []byte) (uint8, error) { + d, err := aggregatorB.UnpackDecimals(data) + if err != nil { + return 0, errors.Errorf("decode decimals: %w", err) + } + return d, nil +} + +/* ───────── market id re-derivation ───────── */ + +// marketParamsArgs is the ABI tuple of Morpho MarketParams, used to recompute a market id. It encodes +// the exact (address,address,address,address,uint256) tuple Morpho's Id library hashes — kept hand-built +// (vs the getter ABI, whose outputs are flattened, not a bare abi.encode of a tuple) so deriveMarketID +// stays byte-exact with the on-chain id (pinned in marketid_test.go). +var marketParamsArgs = abi.Arguments{{Type: mustTupleType([]abi.ArgumentMarshaling{ + {Name: "loanToken", Type: "address"}, + {Name: "collateralToken", Type: "address"}, + {Name: "oracle", Type: "address"}, + {Name: "irm", Type: "address"}, + {Name: "lltv", Type: "uint256"}, +})}} + +func mustTupleType(components []abi.ArgumentMarshaling) abi.Type { + t, err := abi.NewType("tuple", "", components) + if err != nil { + panic("redstoneoev: market params tuple type: " + err.Error()) + } + return t +} + +// deriveMarketID recomputes a Morpho market id = keccak256(abi.encode(MarketParams)), used to verify a +// resolved id against the params Morpho returned for it — a spoofed or non-existent id (Morpho returns +// zero params) re-derives to a different hash and is dropped (fail closed). +func deriveMarketID(p abiMarketParams) (common.Hash, error) { + enc, err := marketParamsArgs.Pack(p) + if err != nil { + return common.Hash{}, errors.Errorf("encode market params: %w", err) + } + return crypto.Keccak256Hash(enc), nil +} + +const maxFeedDecimals = 36 + +// reader performs the OEV on-chain reads, batching via Multicall3. Nothing here runs on the hot path. +type reader struct { + chain *chain.Client + log logr.Logger + decimals *chain.Decimals + // mu guards the two adapter caches below: both are read from the run-loop and discovery goroutines + // (refreshMarkets / discoverMarkets), so the map access is locked — the RPC resolve runs unlocked, only + // the cache read/write takes mu. + mu sync.Mutex + adapterLoan map[common.Address]common.Address // adapter.vault().asset(), resolved once (immutable) + redeemColl map[common.Address][]common.Address // adapter's redeemable collateral set, resolved once (stable) +} + +func feedDecimalsInBounds(loanDec, ethDec uint8) bool { + return loanDec <= maxFeedDecimals && ethDec <= maxFeedDecimals +} + +func feedFresh(updatedAt, nowSec, maxAge int64) bool { + age := nowSec - updatedAt + return age >= 0 && age <= maxAge +} + +func newReader(c *chain.Client, log logr.Logger) *reader { + return &reader{ + chain: c, log: log, + decimals: chain.NewDecimals(c), + adapterLoan: map[common.Address]common.Address{}, + redeemColl: map[common.Address][]common.Address{}, + } +} + +// adapterLoanToken returns the adapter's vault loan token (adapter.vault().asset()), caching the result +// (immutable). Up to two multicalls when uncached: vault(), then asset() on the vault. Returns the zero +// address (and ok=false) when either read fails — the market then resolves to no quote (fail closed). +func (r *reader) adapterLoanToken(ctx context.Context, adapter common.Address) (common.Address, bool, error) { + r.mu.Lock() + lt, ok := r.adapterLoan[adapter] + r.mu.Unlock() + if ok { + return lt, true, nil + } + vault, err := r.callAddress(ctx, adapter, llAdapter.PackVault(), llAdapter.UnpackVault) + if err != nil { + return common.Address{}, false, err + } + if vault == (common.Address{}) { + return common.Address{}, false, nil // vault() reverted / didn't decode → fail closed + } + asset, err := r.callAddress(ctx, vault, erc4626b.PackAsset(), erc4626b.UnpackAsset) + if err != nil { + return common.Address{}, false, err + } + if asset == (common.Address{}) { + return common.Address{}, false, nil // asset() reverted / didn't decode → fail closed + } + r.mu.Lock() + r.adapterLoan[adapter] = asset + r.mu.Unlock() + return asset, true, nil +} + +type adapterSnapshot struct { + loan common.Address + redeemable []common.Address + filler bool +} + +func (r *reader) readAdapterSnapshot(ctx context.Context, callback, adapter common.Address) (adapterSnapshot, error) { + loan, ok, err := r.adapterLoanToken(ctx, adapter) + if err != nil { + return adapterSnapshot{}, errors.Errorf("adapter loan token: %w", err) + } + if !ok || loan == (common.Address{}) { + return adapterSnapshot{}, errors.New("adapter loan token unresolved") + } + redeemable, err := r.readRedeemableCollaterals(ctx, adapter) + if err != nil { + return adapterSnapshot{}, errors.Errorf("adapter redeemable collateral: %w", err) + } + if len(redeemable) == 0 { + return adapterSnapshot{}, errors.New("adapter redeemable collateral unresolved") + } + filler, err := r.ReadFillerStatus(ctx, callback, adapter) + if err != nil { + return adapterSnapshot{}, errors.Errorf("adapter filler status: %w", err) + } + return adapterSnapshot{loan: loan, redeemable: redeemable, filler: filler}, nil +} + +// callAddress reads a single address-returning view method off `target` in one multicall (the call packed +// by `data`, the return decoded by `unpack` — the binding's typed PackXxx/UnpackXxx), returning the zero +// address (not an error) when the sub-call reverts or doesn't decode — only an RPC failure surfaces as an +// error. So a zero-address result means "fail closed" to the caller. +func (r *reader) callAddress(ctx context.Context, target common.Address, data []byte, unpack func([]byte) (common.Address, error)) (common.Address, error) { + res, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: target, AllowFailure: true, Data: data}, + }) + if err != nil { + return common.Address{}, err + } + if len(res) != 1 || !res[0].Success { + return common.Address{}, nil // sub-call reverted → fail closed (zero address) + } + out, derr := unpack(res[0].ReturnData) + if derr != nil { + out = common.Address{} // didn't decode → fail closed (zero address) + } + return out, nil +} + +// ReadLoanEthRate composes live loanPerEth from loan/USD and ETH/USD feeds. Nil means no usable feed value. +func (r *reader) ReadLoanEthRate(ctx context.Context, adapter common.Address, feed *loanEthFeed, now time.Time) *big.Int { + if feed == nil { + return nil + } + token, ok, err := r.adapterLoanToken(ctx, adapter) + if err != nil || !ok { + return nil + } + res, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: feed.LoanUsdFeed, AllowFailure: true, Data: aggregatorB.PackLatestRoundData()}, + {Target: feed.LoanUsdFeed, AllowFailure: true, Data: aggregatorB.PackDecimals()}, + {Target: feed.EthUsdFeed, AllowFailure: true, Data: aggregatorB.PackLatestRoundData()}, + {Target: feed.EthUsdFeed, AllowFailure: true, Data: aggregatorB.PackDecimals()}, + }) + if err != nil || !allSuccess(res, 4) { + return nil + } + loanAns, loanUp, e1 := decodeLatestRoundData(res[0].ReturnData) + loanDecFeed, e2 := decodeDecimals(res[1].ReturnData) + ethAns, ethUp, e3 := decodeLatestRoundData(res[2].ReturnData) + ethDecFeed, e4 := decodeDecimals(res[3].ReturnData) + if e1 != nil || e2 != nil || e3 != nil || e4 != nil { + return nil + } + if !feedDecimalsInBounds(loanDecFeed, ethDecFeed) { + r.log.Error(errors.New("feed decimals out of bounds"), + "loanPerEth feed rejected", "loanFeedDec", loanDecFeed, "ethFeedDec", ethDecFeed, "max", maxFeedDecimals) + return nil + } + nowSec, maxAge := now.Unix(), int64((feed.MaxAge+time.Second-1)/time.Second) + if !feedFresh(loanUp.Int64(), nowSec, maxAge) || !feedFresh(ethUp.Int64(), nowSec, maxAge) { + r.log.V(1).Info("loan/ETH rate feeds stale", + "loanFeed", feed.LoanUsdFeed.Hex(), "loanAgeSec", nowSec-loanUp.Int64(), + "ethFeed", feed.EthUsdFeed.Hex(), "ethAgeSec", nowSec-ethUp.Int64(), + "maxAgeSec", maxAge) + return nil + } + loanDec, e := r.decimals.Get(ctx, token) + if e != nil { + return nil + } + return composeLoanPerEth(ethAns, loanAns, int(ethDecFeed), int(loanDecFeed), loanDec) +} + +// readRedeemableCollaterals returns the adapter's redeemable collateral SET (the markets its loan token +// can liquidate into): getTokensToRedeemLength() then tokensToRedeem(0..n-1) batched in one multicall on +// the adapter. The set is stable, so the result is cached (mirrors the adapterLoan immutable cache). Fails +// CLOSED — returns nil (no discovery this round) when the length read reverts/doesn't decode or any +// tokensToRedeem entry fails — so a partial read never narrows market discovery to a wrong subset. +func (r *reader) readRedeemableCollaterals(ctx context.Context, adapter common.Address) ([]common.Address, error) { + r.mu.Lock() + c, ok := r.redeemColl[adapter] + r.mu.Unlock() + if ok { + return slices.Clone(c), nil + } + lenRes, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: adapter, AllowFailure: true, Data: llAdapter.PackGetTokensToRedeemLength()}, + }) + if err != nil { + return nil, err + } + count, ok := decodeRedeemCount(lenRes) + if !ok { + return nil, nil // length read reverted / didn't decode → fail closed (no discovery) + } + if count == 0 { + r.mu.Lock() + r.redeemColl[adapter] = nil // an empty set is a valid (cached) answer + r.mu.Unlock() + return nil, nil + } + calls := make([]chain.Call, count) + for i := range count { + calls[i] = chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackTokensToRedeem(big.NewInt(int64(i)))} + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + toks, ok := decodeRedeemTokens(res, count) + if !ok { + return nil, nil // a partial/undecodable read → fail closed (don't cache a wrong subset) + } + r.mu.Lock() + r.redeemColl[adapter] = slices.Clone(toks) + r.mu.Unlock() + return toks, nil +} + +// decodeRedeemCount decodes getTokensToRedeemLength() from its single-call result into a non-negative +// int64-bounded count, returning ok=false on a reverted/undecodable/absurd length (caller fails closed). +// Pure → unit-testable. +func decodeRedeemCount(res []chain.CallResult) (int, bool) { + if len(res) != 1 || !res[0].Success { + return 0, false + } + n, err := llAdapter.UnpackGetTokensToRedeemLength(res[0].ReturnData) + if err != nil || n == nil || n.Sign() < 0 || !n.IsInt64() { + return 0, false + } + return int(n.Int64()), true +} + +// decodeRedeemTokens decodes the tokensToRedeem(i) multicall results into the collateral set, returning +// ok=false if any sub-call failed/didn't decode or yielded the zero address — the caller then fails closed. +// Pure (no I/O) so the strided decode is unit-testable against hand-packed CallResults. +func decodeRedeemTokens(res []chain.CallResult, count int) ([]common.Address, bool) { + if len(res) != count { + return nil, false + } + out := make([]common.Address, 0, count) + for i := range res { + if !res[i].Success { + return nil, false + } + tok, err := llAdapter.UnpackTokensToRedeem(res[i].ReturnData) + if err != nil || tok == (common.Address{}) { + return nil, false + } + out = append(out, tok) + } + return out, true +} + +// verifyAdapterPair filters resolved market params to those the adapter can actually liquidate: loan token +// == the adapter's loan AND collateral ∈ the adapter's redeemable set. params come from ResolveParams (each +// already keccak-verified against its id), so this is the pair half of the on-chain verification. Pure → +// unit-testable. +func verifyAdapterPair(params map[common.Hash]abiMarketParams, adapterLoan common.Address, redeemable []common.Address) []common.Hash { + redeem := make(map[common.Address]bool, len(redeemable)) + for _, t := range redeemable { + redeem[t] = true + } + out := make([]common.Hash, 0, len(params)) + for id, p := range params { + if p.LoanToken == adapterLoan && redeem[p.CollateralToken] { + out = append(out, id) + } + } + return out +} + +// MarketInfo is a market's params plus its API snapshot state. The serving adapter is NOT here: it is the +// solver's single configured adapter (cfg.Adapter), and its redemption quote travels separately in snapshot. +type MarketInfo struct { + Params abiMarketParams + State morpho.MarketState +} + +// ResolveParams reads idToMarketParams for each id in ONE multicall and returns the immutable market +// params, keyed by id. Each id is verified by re-deriving keccak256(abi.encode(params)) and dropping +// any mismatch — so a non-existent / spoofed id (Morpho returns zero params) fails closed. Params are +// immutable per id, so the monitor caches the result and only calls this for not-yet-seen ids. +func (r *reader) ResolveParams(ctx context.Context, morpho common.Address, ids []common.Hash) (map[common.Hash]abiMarketParams, error) { + if len(ids) == 0 { + return map[common.Hash]abiMarketParams{}, nil + } + calls := make([]chain.Call, len(ids)) + for i, id := range ids { + calls[i] = chain.Call{Target: morpho, AllowFailure: true, Data: morphoB.PackIdToMarketParams(id)} + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("resolveParams: got %d results, want %d", len(res), len(calls)) + } + out := make(map[common.Hash]abiMarketParams, len(ids)) + for i, id := range ids { + if !res[i].Success { + continue + } + mp, derr := decodeMarketParams(res[i].ReturnData) + if derr != nil { + continue + } + if derived, verr := deriveMarketID(mp); verr != nil || derived != id { + r.log.V(1).Info("market id mismatch; dropping", "id", id.Hex()) // fail closed (unknown/spoofed id) + continue + } + out[id] = mp + } + return out, nil +} + +// ReadAdapterQuotes reads only the single configured LiquidLane adapter's quote for served markets. API +// mode uses this while Morpho market state and positions come from the indexer snapshot. +func (r *reader) ReadAdapterQuotes(ctx context.Context, params map[common.Hash]abiMarketParams, adapter common.Address, serve map[common.Hash]bool) (map[common.Hash]*AdapterQuote, error) { + if len(params) == 0 { + return map[common.Hash]*AdapterQuote{}, nil + } + ids := slices.SortedFunc(maps.Keys(params), common.Hash.Cmp) + tokens := make([]common.Address, 0, len(ids)*2) + for _, id := range ids { + p := params[id] + tokens = append(tokens, p.LoanToken, p.CollateralToken) + } + decs, err := r.decimals.GetMany(ctx, tokens) + if err != nil { + return nil, err + } + + type slot struct { + id common.Hash + at int + } + var slots []slot + var calls []chain.Call + for _, id := range ids { + if !serve[id] { + continue + } + p := params[id] + slots = append(slots, slot{id: id, at: len(calls)}) + calls = append(calls, + chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackGetMaxRate(p.CollateralToken)}, + chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackGetMaxAssets(p.CollateralToken)}, + chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackPaused()}, + ) + } + if len(calls) == 0 { + return map[common.Hash]*AdapterQuote{}, nil + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("adapterQuotes: got %d results, want %d", len(res), len(calls)) + } + out := make(map[common.Hash]*AdapterQuote, len(slots)) + for _, s := range slots { + p := params[s.id] + out[s.id] = buildQuote(res[s.at], res[s.at+1], res[s.at+2], decs, p.LoanToken, p.CollateralToken) + } + return out, nil +} + +// buildQuote assembles the adapter redemption quote from the three adapter sub-call results, returning +// nil (no biddable exit) when paused, missing either token's decimals, or a non-positive rate/liquidity. +// An UNREADABLE pause state — paused() reverted or didn't decode — is treated as PAUSED (fail closed): we +// must not bid a leg whose adapter might be paused (the swap would revert), mirroring every other guard here. +func buildQuote(rateRes, maxAssetsRes, pausedRes chain.CallResult, decs map[common.Address]int, loanTok, collTok common.Address) *AdapterQuote { + loanDec, okLoan := decs[loanTok] + collDec, okColl := decs[collTok] + if !okLoan || !okColl { + return nil + } + if !pausedRes.Success { + return nil // unknown pause state ⇒ treat as paused (fail closed) + } + p, perr := llAdapter.UnpackPaused(pausedRes.ReturnData) + if perr != nil || p { + return nil // undecodable ⇒ fail closed; explicitly paused ⇒ no quote + } + if !rateRes.Success || !maxAssetsRes.Success { + return nil + } + rate, e1 := llAdapter.UnpackGetMaxRate(rateRes.ReturnData) + maxAssets, e2 := llAdapter.UnpackGetMaxAssets(maxAssetsRes.ReturnData) + if e1 != nil || e2 != nil || rate.Sign() <= 0 || maxAssets.Sign() <= 0 { + return nil + } + return &AdapterQuote{ + MaxRate: rate, MaxAssets: maxAssets, + LoanScale: chain.Exp10(loanDec), CollScale: chain.Exp10(collDec), // precompute for the hot path + } +} + +// ExecutorState is the signer's accounting on the RedStone Executor. +type ExecutorState struct { + Nonce *big.Int + Deposit *big.Int + Locked bool +} + +// ReadExecutorState reads nonces/deposits/locked for the signer in one multicall. +func (r *reader) ReadExecutorState(ctx context.Context, executor, signer common.Address) (ExecutorState, error) { + res, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: executor, AllowFailure: true, Data: executorB.PackNonces(signer)}, + {Target: executor, AllowFailure: true, Data: executorB.PackDeposits(signer)}, + {Target: executor, AllowFailure: true, Data: executorB.PackLocked(signer)}, + }) + if err != nil { + return ExecutorState{}, err + } + if !allSuccess(res, 3) { + return ExecutorState{}, errors.New("executor state read reverted") + } + nonce, e1 := executorB.UnpackNonces(res[0].ReturnData) + deposit, e2 := executorB.UnpackDeposits(res[1].ReturnData) + locked, e3 := executorB.UnpackLocked(res[2].ReturnData) + if e1 != nil || e2 != nil || e3 != nil { + return ExecutorState{}, errors.New("executor state decode failed") + } + return ExecutorState{Nonce: nonce, Deposit: deposit, Locked: locked}, nil +} + +// ReadGasPredictorState caches the LiquidLane/vault balances needed to classify each selected leg's route. +func (r *reader) ReadGasPredictorState(ctx context.Context, adapter common.Address, collaterals []common.Address) (*gasPredictorState, error) { + colls := dedupeAddresses(collaterals) + if len(colls) == 0 { + return nil, nil + } + head, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: adapter, AllowFailure: true, Data: llAdapter.PackOwner()}, + {Target: adapter, AllowFailure: true, Data: llAdapter.PackMarketMaker()}, + {Target: adapter, AllowFailure: true, Data: llAdapter.PackVault()}, + }) + if err != nil { + return nil, err + } + if !allSuccess(head, 3) { + return nil, errors.New("gas predictor head read reverted") + } + owner, e1 := llAdapter.UnpackOwner(head[0].ReturnData) + marketMaker, e2 := llAdapter.UnpackMarketMaker(head[1].ReturnData) + vault, e3 := llAdapter.UnpackVault(head[2].ReturnData) + if e1 != nil || e2 != nil || e3 != nil || vault == (common.Address{}) { + return nil, errors.New("gas predictor head decode failed") + } + + calls := []chain.Call{ + {Target: vault, AllowFailure: true, Data: vaultV2B.PackFreeAssets()}, + {Target: vault, AllowFailure: true, Data: vaultV2B.PackWithdrawable()}, + } + for _, coll := range colls { + calls = append(calls, chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackAcquireBalance(coll, owner)}) + if marketMaker != owner { + calls = append(calls, chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackAcquireBalance(coll, marketMaker)}) + } + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if !allSuccess(res, len(calls)) { + return nil, errors.New("gas predictor state read reverted") + } + free, e1 := vaultV2B.UnpackFreeAssets(res[0].ReturnData) + withdrawable, e2 := vaultV2B.UnpackWithdrawable(res[1].ReturnData) + if e1 != nil || e2 != nil || free == nil || withdrawable == nil { + return nil, errors.New("gas predictor vault decode failed") + } + st := &gasPredictorState{ + FreeAssets: free, + Withdrawable: withdrawable, + Acquire: make(map[common.Address]*big.Int, len(colls)), + } + idx := 2 + for _, coll := range colls { + ownerBal, derr := llAdapter.UnpackAcquireBalance(res[idx].ReturnData) + idx++ + if derr != nil || ownerBal == nil { + return nil, errors.New("gas predictor acquire decode failed") + } + total := new(big.Int).Set(ownerBal) + if marketMaker != owner { + mmBal, merr := llAdapter.UnpackAcquireBalance(res[idx].ReturnData) + idx++ + if merr != nil || mmBal == nil { + return nil, errors.New("gas predictor market-maker acquire decode failed") + } + total.Add(total, mmBal) + } + st.Acquire[coll] = total + } + return st, nil +} + +func dedupeAddresses(in []common.Address) []common.Address { + seen := make(map[common.Address]bool, len(in)) + out := make([]common.Address, 0, len(in)) + for _, a := range in { + if a == (common.Address{}) || seen[a] { + continue + } + seen[a] = true + out = append(out, a) + } + return out +} + +// allSuccess reports whether a fixed-shape multicall returned exactly n results and every one succeeded — +// the reverted-guard for single/triple-call view reads before decoding. +func allSuccess(res []chain.CallResult, n int) bool { + if len(res) != n { + return false + } + for i := range res { + if !res[i].Success { + return false + } + } + return true +} diff --git a/internal/solvers/redstoneoev/config.go b/internal/solvers/redstoneoev/config.go new file mode 100644 index 00000000..983218cf --- /dev/null +++ b/internal/solvers/redstoneoev/config.go @@ -0,0 +1,262 @@ +package redstoneoev + +import ( + "math/big" + "net/url" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/parse" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +// rawConfig mirrors the YAML shape; strings/ms are parsed into typed values in parseConfig. +type rawConfig struct { + WS rawWS `yaml:"ws"` + Executor string `yaml:"executor"` + Callback string `yaml:"callback"` + Adapter string `yaml:"adapter"` + MorphoAPIURL string `yaml:"morphoApiUrl"` + DiscoveryMaxHF *float64 `yaml:"discoveryMaxHealthFactor"` + MaxTrackedPositions *int `yaml:"maxTrackedPositions"` + LoanEthFeed *rawLoanEthFeed `yaml:"loanEthFeed"` + Bid rawBid `yaml:"bid"` + Sizing rawSizing `yaml:"sizing"` + Breaker rawBreaker `yaml:"breaker"` + Intervals rawIntervals `yaml:"intervals"` +} + +type rawWS struct { + URL string `yaml:"url"` + APIKeyEnv string `yaml:"apiKeyEnv"` +} + +type rawBid struct { + BidEth string `yaml:"bidEth"` + MinBundleProfitBidBps *int `yaml:"minBundleProfitBidBps"` + TotalBundleProfitBps *int `yaml:"totalBundleProfitBps"` + MaxTxGasPriceWei string `yaml:"maxTxGasPriceWei"` +} + +type rawLoanEthFeed struct { + EthUsd string `yaml:"ethUsd"` + LoanUsd string `yaml:"loanUsd"` + MaxAgeMs *int `yaml:"maxAgeMs"` +} + +type loanEthFeed struct { + LoanUsdFeed common.Address + EthUsdFeed common.Address + MaxAge time.Duration +} + +type rawBreaker struct { + MaxFailures int `yaml:"maxFailures"` + WindowMs *int `yaml:"windowMs"` +} + +type rawSizing struct { + AllowFullLiquidation *bool `yaml:"allowFullLiquidation"` + SwapHaircutBps *int `yaml:"swapHaircutBps"` +} + +type rawIntervals struct { + // Pointers so an omitted field (→ default) is distinguishable from a set-but-invalid one: a present + // non-positive interval is a misconfiguration and is rejected, never silently defaulted. + OpsPollMs *int `yaml:"opsPollMs"` + MonitorPollMs *int `yaml:"monitorPollMs"` +} + +// Config is the validated, typed redstone-oev configuration. +type Config struct { + WSURL string + APIKeyEnv string + + Executor common.Address + Callback common.Address + Adapter common.Address + + // MorphoAPIURL is the Morpho GraphQL endpoint the solver polls for Morpho market state and at-risk + // positions. It is required by the production monitor factory. + MorphoAPIURL string + // DiscoveryMaxHealthFactor is the API at-risk band ceiling: positions with healthFactor ≤ this are + // snapshotted, then local Morpho math decides actual liquidatability at the auction price. + DiscoveryMaxHealthFactor float64 + // MaxTrackedPositions is the Morpho API `first` window and hard in-memory position cap. + MaxTrackedPositions int + + BidWei *big.Int + LoanEthFeed *loanEthFeed + MinBundleProfitBidBps int + TotalBundleProfitBps int + MaxTxGasPrice *big.Int + Sizing SizingParams + + BreakerMaxFailures int + BreakerWindow time.Duration + + OpsPoll time.Duration + MonitorPoll time.Duration +} + +const ( + defaultAllowFullLiquidation = true // target 100% collateral unless explicitly disabled + defaultSwapHaircut = 200 // 2% + defaultMaxTxGasPrice = 60_000_000_000 // 60 gwei + defaultFeedMaxAge = time.Hour // generous Chainlink-style heartbeat bound + defaultBreakerFails = 3 // halt after 3 failed liquidations in the window + defaultBreakerWindow = time.Hour + defaultOpsPoll = 30 * time.Second + defaultMonitorPoll = 10 * time.Second // cadence of the monitor snapshot poll + defaultDiscoveryMaxHF = 1.30 // API at-risk band ceiling (spec §3.2: within 30% of liquidation) + defaultMaxTrackedPositions = 10_000 // API `first` window + in-memory at-risk cap +) + +// parseConfig decodes and validates the opaque redstone-oev solver config block. +func parseConfig(node yaml.Node) (*Config, error) { + var raw rawConfig + if err := solver.DecodeStrict(node, &raw); err != nil { // reject unknown keys → typos fail fast + return nil, err + } + if raw.WS.URL == "" { + return nil, errors.New("ws.url is required") + } + if raw.WS.APIKeyEnv == "" { + return nil, errors.New("ws.apiKeyEnv is required") + } + executor, err := parse.Address(raw.Executor, "executor") + if err != nil { + return nil, err + } + callback, err := parse.Address(raw.Callback, "callback") + if err != nil { + return nil, err + } + + breakerWindow, err := parse.MsDuration(raw.Breaker.WindowMs, defaultBreakerWindow, "breaker.windowMs") + if err != nil { + return nil, err + } + opsPoll, err := parse.MsDuration(raw.Intervals.OpsPollMs, defaultOpsPoll, "intervals.opsPollMs") + if err != nil { + return nil, err + } + monitorPoll, err := parse.MsDuration(raw.Intervals.MonitorPollMs, defaultMonitorPoll, "intervals.monitorPollMs") + if err != nil { + return nil, err + } + + // SwapHaircutBps can't use OrDefault: an explicit 0 (no extra haircut) must be distinguishable from + // unset (→ defaultSwapHaircut), so the YAML field is a pointer and only nil falls back to the default. + swapHaircut := defaultSwapHaircut + if raw.Sizing.SwapHaircutBps != nil { + swapHaircut = *raw.Sizing.SwapHaircutBps + } + allowFullLiquidation := defaultAllowFullLiquidation + if raw.Sizing.AllowFullLiquidation != nil { + allowFullLiquidation = *raw.Sizing.AllowFullLiquidation + } + + cfg := &Config{ + WSURL: raw.WS.URL, + APIKeyEnv: raw.WS.APIKeyEnv, + Executor: executor, + Callback: callback, + Sizing: SizingParams{ + AllowFullLiquidation: allowFullLiquidation, + SwapHaircutBps: swapHaircut, + }, + BreakerMaxFailures: parse.OrDefault(raw.Breaker.MaxFailures, defaultBreakerFails), + BreakerWindow: breakerWindow, + OpsPoll: opsPoll, + MonitorPoll: monitorPoll, + } + if cfg.Adapter, err = parse.Address(raw.Adapter, "adapter"); err != nil { + return nil, err // required: sizing needs the adapter's redemption rate; the callback pins it as LiquidLaneAdapter + } + if cfg.BidWei, err = parse.EthToWei(parse.OrDefault(raw.Bid.BidEth, "0"), "bid.bidEth"); err != nil { + return nil, err + } + if cfg.BidWei.Sign() <= 0 { + return nil, errors.New("bid.bidEth must be > 0") + } + if cfg.LoanEthFeed, err = parseLoanEthFeed(raw.LoanEthFeed); err != nil { + return nil, err + } + if cfg.LoanEthFeed == nil { + return nil, errors.New("loanEthFeed is required") + } + if cfg.MaxTxGasPrice, err = parse.Big(parse.OrDefault(raw.Bid.MaxTxGasPriceWei, big.NewInt(defaultMaxTxGasPrice).String()), "bid.maxTxGasPriceWei"); err != nil { + return nil, err + } + if cfg.MaxTxGasPrice.Sign() <= 0 { // signed into the EXECUTOR_V6 bid as the tx.gasprice ceiling; the contract requires it > 0 + return nil, errors.New("bid.maxTxGasPriceWei must be > 0") + } + if raw.Bid.MinBundleProfitBidBps != nil { + if *raw.Bid.MinBundleProfitBidBps < 0 { + return nil, errors.New("bid.minBundleProfitBidBps must be >= 0") + } + cfg.MinBundleProfitBidBps = *raw.Bid.MinBundleProfitBidBps + } + if raw.Bid.TotalBundleProfitBps != nil { + if *raw.Bid.TotalBundleProfitBps < 0 || *raw.Bid.TotalBundleProfitBps > 10_000 { + return nil, errors.New("bid.totalBundleProfitBps must be in [0, 10000]") + } + cfg.TotalBundleProfitBps = *raw.Bid.TotalBundleProfitBps + } + if cfg.Sizing.SwapHaircutBps < 0 || cfg.Sizing.SwapHaircutBps >= 10_000 { + return nil, errors.Errorf("sizing.swapHaircutBps must be in [0, 10000), got %d", cfg.Sizing.SwapHaircutBps) + } + if raw.MorphoAPIURL != "" { + u, perr := url.Parse(raw.MorphoAPIURL) + if perr != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, errors.Errorf("morphoApiUrl must be an absolute http/https URL, got %q", raw.MorphoAPIURL) + } + cfg.MorphoAPIURL = raw.MorphoAPIURL + } + // At-risk band ceiling for API position snapshots (healthFactor_lte). Default 1.30 (spec §3.2). + cfg.DiscoveryMaxHealthFactor = defaultDiscoveryMaxHF + if raw.DiscoveryMaxHF != nil { + if *raw.DiscoveryMaxHF <= 0 { + return nil, errors.Errorf("discoveryMaxHealthFactor must be > 0, got %v", *raw.DiscoveryMaxHF) + } + cfg.DiscoveryMaxHealthFactor = *raw.DiscoveryMaxHF + } + // Bounds the in-memory tracked at-risk set AND doubles as the GraphQL `first` arg; >0 required (0/neg + // would track nothing). + cfg.MaxTrackedPositions = defaultMaxTrackedPositions + if raw.MaxTrackedPositions != nil { + if *raw.MaxTrackedPositions <= 0 { + return nil, errors.Errorf("maxTrackedPositions must be > 0, got %d", *raw.MaxTrackedPositions) + } + cfg.MaxTrackedPositions = *raw.MaxTrackedPositions + } + return cfg, nil +} + +// parseLoanEthFeed validates the single loan token's oracle feed config (nil -> no feed). Both feed +// addresses are required; maxAgeMs defaults to defaultFeedMaxAge when unset and must be positive when set. +func parseLoanEthFeed(in *rawLoanEthFeed) (*loanEthFeed, error) { + if in == nil { + return nil, nil + } + loanFeed, err := parse.Address(in.LoanUsd, "loanEthFeed.loanUsd") + if err != nil { + return nil, err + } + ethFeed, err := parse.Address(in.EthUsd, "loanEthFeed.ethUsd") + if err != nil { + return nil, err + } + maxAge := defaultFeedMaxAge + if in.MaxAgeMs != nil { + if *in.MaxAgeMs <= 0 { + return nil, errors.New("loanEthFeed.maxAgeMs must be > 0") + } + maxAge = time.Duration(*in.MaxAgeMs) * time.Millisecond + } + return &loanEthFeed{LoanUsdFeed: loanFeed, EthUsdFeed: ethFeed, MaxAge: maxAge}, nil +} diff --git a/internal/solvers/redstoneoev/eip191.go b/internal/solvers/redstoneoev/eip191.go new file mode 100644 index 00000000..e16f1283 --- /dev/null +++ b/internal/solvers/redstoneoev/eip191.go @@ -0,0 +1,69 @@ +package redstoneoev + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/signer" +) + +// executorV6Domain is the RedStone Atom signature version string, the first field of the signed +// payload (see the verified Executor source, docs/OEV-PLAN.md §6.2). +const executorV6Domain = "EXECUTOR_V6" + +// executorV6Args is the ABI tuple the Executor recovers the solver from: +// +// keccak256(abi.encode("EXECUTOR_V6", chainId, operationCallback, keccak256(operationData), +// bidAmount, nonce, maxTxGasPrice)) +// +// standard (non-packed) ABI encoding, matching ethers AbiCoder.defaultAbiCoder().encode. +var executorV6Args = abi.Arguments{ + {Type: mustType("string")}, + {Type: mustType("uint256")}, // chainId + {Type: mustType("address")}, // operationCallback + {Type: mustType("bytes32")}, // keccak256(operationData) + {Type: mustType("uint256")}, // bidAmount (wei) + {Type: mustType("uint256")}, // nonce (strictly ascending) + {Type: mustType("uint256")}, // maxTxGasPrice +} + +// ExecutorV6Digest is the inner digest the Executor hashes before EIP-191 wrapping: +// keccak256(abi.encode("EXECUTOR_V6", chainId, callback, opDataHash, bid, nonce, maxTxGasPrice)). +func ExecutorV6Digest(chainID *big.Int, callback common.Address, opDataHash common.Hash, bid, nonce, maxTxGasPrice *big.Int) (common.Hash, error) { + enc, err := executorV6Args.Pack(executorV6Domain, chainID, callback, opDataHash, bid, nonce, maxTxGasPrice) + if err != nil { + return common.Hash{}, errors.Errorf("encode EXECUTOR_V6: %w", err) + } + return crypto.Keccak256Hash(enc), nil +} + +// SignBid produces the 65-byte EIP-191 (personal_sign) signature over the EXECUTOR_V6 digest that the +// auctioneer forwards and the Executor verifies via ECDSA.recover(toEthSignedMessageHash(digest)). +// The signer EOA must be the wallet holding the Executor deposit (§6.2). +func SignBid(sgnr signer.Signer, chainID *big.Int, callback common.Address, operationData []byte, bid, nonce, maxTxGasPrice *big.Int) ([]byte, error) { + digest, err := ExecutorV6Digest(chainID, callback, crypto.Keccak256Hash(operationData), bid, nonce, maxTxGasPrice) + if err != nil { + return nil, err + } + return sgnr.SignHash(ethSignedMessageHash(digest)) +} + +// ethSignedMessageHash applies the EIP-191 personal_sign prefix to a 32-byte digest: +// keccak256("\x19Ethereum Signed Message:\n32" || digest) — Solady/OZ MessageHashUtils.toEthSignedMessageHash. +// accounts.TextHash computes exactly this prefix (len(digest)==32) for a 32-byte input. +func ethSignedMessageHash(digest common.Hash) common.Hash { + return common.BytesToHash(accounts.TextHash(digest.Bytes())) +} + +func mustType(t string) abi.Type { + typ, err := abi.NewType(t, "", nil) + if err != nil { + panic("redstoneoev: abi type " + t + ": " + err.Error()) + } + return typ +} diff --git a/internal/solvers/redstoneoev/epoch.go b/internal/solvers/redstoneoev/epoch.go new file mode 100644 index 00000000..0ce3b7a7 --- /dev/null +++ b/internal/solvers/redstoneoev/epoch.go @@ -0,0 +1,14 @@ +package redstoneoev + +import ( + "time" +) + +type readEpoch struct { + Block uint64 + At time.Time +} + +func newReadEpoch(block uint64, at time.Time) readEpoch { + return readEpoch{Block: block, At: at} +} diff --git a/internal/solvers/redstoneoev/fillerauth.go b/internal/solvers/redstoneoev/fillerauth.go new file mode 100644 index 00000000..e3493249 --- /dev/null +++ b/internal/solvers/redstoneoev/fillerauth.go @@ -0,0 +1,61 @@ +package redstoneoev + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/chain" +) + +// ReadFillerStatus checks the adapter's caller predicate: +// callback == marketMaker || callback == owner || isFiller(marketMaker, callback). +func (r *reader) ReadFillerStatus(ctx context.Context, callback, adapter common.Address) (bool, error) { + res, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: adapter, AllowFailure: true, Data: llAdapter.PackMarketMaker()}, + {Target: adapter, AllowFailure: true, Data: llAdapter.PackOwner()}, + }) + if err != nil { + return false, err + } + authorized, mm, needFiller := resolveFillerAuth(callback, res) + if !needFiller { + return authorized, nil + } + fRes, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: adapter, AllowFailure: true, Data: llAdapter.PackIsFiller(mm, callback)}, + }) + if err != nil { + return false, err + } + if len(fRes) == 1 && fRes[0].Success { + if ok, e := llAdapter.UnpackIsFiller(fRes[0].ReturnData); e == nil { + return ok, nil + } + } + return false, nil // isFiller unreadable → fail closed +} + +func resolveFillerAuth(callback common.Address, res []chain.CallResult) (authorized bool, marketMaker common.Address, needFiller bool) { + var mm common.Address + hasMM, direct := false, false + if len(res) > 0 && res[0].Success { + if v, e := llAdapter.UnpackMarketMaker(res[0].ReturnData); e == nil { + mm, hasMM = v, v != (common.Address{}) + direct = mm == callback + } + } + if len(res) > 1 && res[1].Success { + if owner, e := llAdapter.UnpackOwner(res[1].ReturnData); e == nil && owner == callback { + direct = true + } + } + switch { + case direct: + return true, mm, false // marketMaker/owner == callback + case hasMM: + return false, mm, true // need isFiller(marketMaker, callback) + default: + return false, mm, false // no marketMaker + not owned → fail closed + } +} diff --git a/internal/solvers/redstoneoev/gaspredictor.go b/internal/solvers/redstoneoev/gaspredictor.go new file mode 100644 index 00000000..f3478d48 --- /dev/null +++ b/internal/solvers/redstoneoev/gaspredictor.go @@ -0,0 +1,238 @@ +package redstoneoev + +import ( + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +type gasRoute uint8 + +const ( + gasRouteUnknown gasRoute = iota + gasRouteAcquire + gasRouteAllocate + gasRouteDeallocate +) + +const ( + gasRouteUnknownLabel = "unknown" + + // Calibrated on the Sepolia OEV no-preview callback fork. fixedGasUnits adds RedStone overhead; first + // route units include the cold Morpho/callback/adapter path, later route units are marginal. + gasBaseUnits uint64 = 100_000 + gasFirstAcquireLeg uint64 = 300_000 + gasAdditionalAcquireLeg uint64 = 140_000 + gasFirstAllocateLeg uint64 = 530_000 + gasAdditionalAllocateLeg uint64 = 350_000 + gasFirstDeallocateLeg uint64 = 650_000 + gasAdditionalDeallocateLeg uint64 = 450_000 + gasFirstUnknownLeg uint64 = 850_000 + gasAdditionalUnknownLeg uint64 = 650_000 + + // RedStone debits (gasUsed + 35k) * tx.gasprice from the Executor deposit after settlement. Their + // price update path adds roughly 40k per updated feed before our callback runs. Both are fixed bundle + // costs for economics and gas-limit sizing; per-leg route units are converted to loan floors by buildBid. + gasExecutorDebitSurcharge uint64 = 35_000 + gasPriceUpdatePerFeed uint64 = 40_000 + + redstoneExecutorMaxGasUnits uint64 = 2_000_000 + bundleGasLimitSafetyBps uint64 = 8_500 + defaultPriceUpdateFeeds = 1 +) + +type gasPredictorState struct { + FreeAssets *big.Int + Withdrawable *big.Int + Acquire map[common.Address]*big.Int +} + +type gasPrediction struct { + Units uint64 + Routes []gasRoute +} + +func cloneBig(v *big.Int) *big.Int { + if v == nil { + return nil + } + return new(big.Int).Set(v) +} + +func gasPredictionForBundle(b chosenBundle, st *gasPredictorState) gasPrediction { + return gasPredictionForBundleFeeds(b, st, defaultPriceUpdateFeeds) +} + +func gasPredictionForBundleFeeds(b chosenBundle, st *gasPredictorState, feedCount int) gasPrediction { + legUnits, routes := gasLegPredictionForBundle(b, st) + return gasPrediction{Units: saturatingAddUint64(fixedGasUnits(feedCount), legUnits), Routes: routes} +} + +func gasLegPredictionForBundle(b chosenBundle, st *gasPredictorState) (uint64, []gasRoute) { + if len(b.legs) == 0 { + return 0, nil + } + routes := make([]gasRoute, 0, len(b.legs)) + if st == nil || st.FreeAssets == nil || st.Withdrawable == nil { + var total uint64 + for i := range b.legs { + routes = append(routes, gasRouteUnknown) + total = saturatingAddUint64(total, gasUnitsForRouteAt(gasRouteUnknown, i == 0)) + } + return total, routes + } + acquire := make(map[common.Address]*big.Int, len(st.Acquire)) + for k, v := range st.Acquire { + acquire[k] = cloneBig(v) + } + free := cloneBig(st.FreeAssets) + withdrawable := cloneBig(st.Withdrawable) + var total uint64 + for i, leg := range b.legs { + coll := common.Address{} + if i < len(b.collaterals) { + coll = b.collaterals[i] + } + route := predictGasRoute(leg.SwapAmountOut, coll, acquire, free, withdrawable) + routes = append(routes, route) + total = saturatingAddUint64(total, gasUnitsForRouteAt(route, i == 0)) + } + return total, routes +} + +func predictGasRoute(swapOut *big.Int, collateral common.Address, acquire map[common.Address]*big.Int, free, withdrawable *big.Int) gasRoute { + if swapOut == nil || swapOut.Sign() <= 0 || free == nil || withdrawable == nil { + return gasRouteUnknown + } + remaining := new(big.Int).Set(swapOut) + if a := acquire[collateral]; a != nil && a.Sign() > 0 { + used := minBig(remaining, a) + remaining.Sub(remaining, used) + a.Sub(a, used) + } + if remaining.Sign() == 0 { + return gasRouteAcquire + } + if free.Cmp(remaining) >= 0 { + free.Sub(free, remaining) + if withdrawable.Cmp(remaining) >= 0 { + withdrawable.Sub(withdrawable, remaining) + } else { + withdrawable.SetInt64(0) + } + return gasRouteAllocate + } + if withdrawable.Cmp(remaining) >= 0 { + withdrawable.Sub(withdrawable, remaining) + free.SetInt64(0) + return gasRouteDeallocate + } + return gasRouteUnknown +} + +func gasUnitsForRoute(route gasRoute) uint64 { + return gasUnitsForRouteAt(route, false) +} + +func gasUnitsForRouteAt(route gasRoute, first bool) uint64 { + switch route { + case gasRouteAcquire: + if first { + return gasFirstAcquireLeg + } + return gasAdditionalAcquireLeg + case gasRouteAllocate: + if first { + return gasFirstAllocateLeg + } + return gasAdditionalAllocateLeg + case gasRouteDeallocate: + if first { + return gasFirstDeallocateLeg + } + return gasAdditionalDeallocateLeg + case gasRouteUnknown: + if first { + return gasFirstUnknownLeg + } + return gasAdditionalUnknownLeg + default: + if first { + return gasFirstUnknownLeg + } + return gasAdditionalUnknownLeg + } +} + +func fixedGasUnits(feedCount int) uint64 { + feeds := uint64(defaultPriceUpdateFeeds) + if feedCount > 0 { + feeds = uint64(feedCount) + } + feedUnits := saturatingMulUint64(gasPriceUpdatePerFeed, feeds) + return saturatingAddUint64(saturatingAddUint64(gasBaseUnits, gasExecutorDebitSurcharge), feedUnits) +} + +func usableBundleGasLimit(headerGasLimit uint64) uint64 { + if headerGasLimit == 0 { + headerGasLimit = redstoneExecutorMaxGasUnits + } + limit := min(headerGasLimit, redstoneExecutorMaxGasUnits) + return saturatingMulUint64(limit, bundleGasLimitSafetyBps) / 10_000 +} + +func bundleFitsGasLimit(b chosenBundle, st *gasPredictorState, headerGasLimit uint64, feedCount int) bool { + return gasPredictionForBundleFeeds(b, st, feedCount).Units <= usableBundleGasLimit(headerGasLimit) +} + +func gasCostNative(units uint64, gasPrice *big.Int) *big.Int { + return new(big.Int).Mul(new(big.Int).SetUint64(units), orZero(gasPrice)) +} + +func (r gasRoute) String() string { + switch r { + case gasRouteAcquire: + return "acquire" + case gasRouteAllocate: + return "allocate" + case gasRouteDeallocate: + return "deallocate" + case gasRouteUnknown: + return gasRouteUnknownLabel + default: + return gasRouteUnknownLabel + } +} + +func gasRoutesString(routes []gasRoute) string { + if len(routes) == 0 { + return "" + } + out := make([]string, len(routes)) + for i, r := range routes { + out[i] = r.String() + } + return strings.Join(out, ",") +} + +func minBig(a, b *big.Int) *big.Int { + if a.Cmp(b) <= 0 { + return new(big.Int).Set(a) + } + return new(big.Int).Set(b) +} + +func saturatingMulUint64(a, b uint64) uint64 { + if a != 0 && b > ^uint64(0)/a { + return ^uint64(0) + } + return a * b +} + +func saturatingAddUint64(a, b uint64) uint64 { + if b > ^uint64(0)-a { + return ^uint64(0) + } + return a + b +} diff --git a/internal/solvers/redstoneoev/metrics.go b/internal/solvers/redstoneoev/metrics.go new file mode 100644 index 00000000..6263a847 --- /dev/null +++ b/internal/solvers/redstoneoev/metrics.go @@ -0,0 +1,113 @@ +package redstoneoev + +import ( + "time" + + "github.com/go-errors/errors" + "github.com/prometheus/client_golang/prometheus" +) + +// metrics are the OEV solver's collectors, registered on the shared Prometheus registry (served at +// the framework's /metrics). All methods are nil-safe so the solver runs unmetered when no registry +// is provided. +type metrics struct { + auctions prometheus.Counter + bids prometheus.Counter + wins prometheus.Counter + failedLiq prometheus.Counter + skips *prometheus.CounterVec + hotPath prometheus.Histogram + gasRatio prometheus.Histogram + deposit prometheus.Gauge + callbackNative prometheus.Gauge + depositLow prometheus.Gauge // 1 when the deposit is below the on-chain MIN_DEPOSIT floor +} + +func newMetrics(reg prometheus.Registerer) (*metrics, error) { + m := &metrics{ + auctions: prometheus.NewCounter(prometheus.CounterOpts{Name: "oev_auctions_total", Help: "OEV auction frames seen."}), + bids: prometheus.NewCounter(prometheus.CounterOpts{Name: "oev_bids_total", Help: "Bids sent (or would-bid in dry-run)."}), + wins: prometheus.NewCounter(prometheus.CounterOpts{Name: "oev_wins_total", Help: "Auctions won (auction-result names our callback)."}), + failedLiq: prometheus.NewCounter(prometheus.CounterOpts{Name: "oev_failed_liquidations_total", Help: "Reverted settlements for our callback (from the WS liquidation-result frame)."}), + skips: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "oev_skips_total", Help: "Auctions not bid on, by reason.", + }, []string{"reason"}), + hotPath: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "oev_hotpath_seconds", Help: "handleAuction wall-clock (the ~400ms budget).", + Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.4, 1}, + }), + gasRatio: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "oev_settlement_gas_actual_predicted_ratio", Help: "Actual receipt gasUsed divided by predicted settlement gas units.", + Buckets: []float64{0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 5}, + }), + deposit: prometheus.NewGauge(prometheus.GaugeOpts{Name: "oev_deposit_wei", Help: "Signer's Executor deposit (wei)."}), + callbackNative: prometheus.NewGauge(prometheus.GaugeOpts{Name: "oev_callback_native_wei", Help: "Callback contract native balance (wei)."}), + depositLow: prometheus.NewGauge(prometheus.GaugeOpts{Name: "oev_deposit_below_floor", Help: "1 when the Executor deposit is below the on-chain MIN_DEPOSIT floor."}), + } + for _, c := range []prometheus.Collector{m.auctions, m.bids, m.wins, m.failedLiq, m.skips, m.hotPath, m.gasRatio, m.deposit, m.callbackNative, m.depositLow} { + if err := reg.Register(c); err != nil { + return nil, errors.Errorf("redstoneoev: register metric: %w", err) + } + } + return m, nil +} + +func (m *metrics) auction() { + if m != nil { + m.auctions.Inc() + } +} + +func (m *metrics) bid() { + if m != nil { + m.bids.Inc() + } +} + +func (m *metrics) won() { + if m != nil { + m.wins.Inc() + } +} + +func (m *metrics) failed() { + if m != nil { + m.failedLiq.Inc() + } +} + +func (m *metrics) skip(reason string) { + if m != nil { + m.skips.WithLabelValues(reason).Inc() + } +} + +func (m *metrics) latency(d time.Duration) { + if m != nil { + m.hotPath.Observe(d.Seconds()) + } +} + +func (m *metrics) settlementGas(predicted, actual uint64) { + if m != nil && predicted > 0 { + m.gasRatio.Observe(float64(actual) / float64(predicted)) + } +} + +func (m *metrics) balances(depositWei, callbackWei float64) { + if m != nil { + m.deposit.Set(depositWei) + m.callbackNative.Set(callbackWei) + } +} + +// depositBelowFloor sets the alarm gauge; "below" now means deposit < MIN_DEPOSIT. +func (m *metrics) depositBelowFloor(below bool) { + if m != nil { + v := 0.0 + if below { + v = 1 + } + m.depositLow.Set(v) + } +} diff --git a/internal/solvers/redstoneoev/monitor.go b/internal/solvers/redstoneoev/monitor.go new file mode 100644 index 00000000..c447b53e --- /dev/null +++ b/internal/solvers/redstoneoev/monitor.go @@ -0,0 +1,379 @@ +package redstoneoev + +import ( + "context" + "math/big" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +const snapshotMaxAuctionLag = 3 * 12 * time.Second + +// snapshot is immutable once stored and read lock-free by the WS goroutine. +type snapshot struct { + markets map[common.Hash]MarketInfo + prices map[common.Hash]*big.Int + quotes map[common.Hash]AdapterQuote + positions map[common.Hash]map[common.Address]morpho.PositionState + + block uint64 + blockTime uint64 +} + +type monitorSource interface { + name() string + run(context.Context) + refresh(context.Context) + snapshot() *snapshot + candidates(auction AuctionMessage, nowTs uint64) []evalItem +} + +// apiMonitor owns the API-backed Morpho snapshot. The run loop is the only snapshot writer. +type apiMonitor struct { + reader *reader + log logr.Logger + + maxPositions int + adapter common.Address + + maxHF float64 + callback common.Address + chainID int64 + + api *morphoClient + monitorPoll time.Duration + + snap atomic.Pointer[snapshot] +} + +func newAPIMonitor(r *reader, log logr.Logger, cfg *Config, chainID int64) *apiMonitor { + m := &apiMonitor{ + reader: r, + log: log.WithName("monitor"), + maxPositions: cfg.MaxTrackedPositions, + adapter: cfg.Adapter, + maxHF: cfg.DiscoveryMaxHealthFactor, + callback: cfg.Callback, + chainID: chainID, + monitorPoll: cfg.MonitorPoll, + api: newMorphoClient(cfg.MorphoAPIURL), + } + m.snap.Store(&snapshot{ + markets: map[common.Hash]MarketInfo{}, + prices: map[common.Hash]*big.Int{}, + quotes: map[common.Hash]AdapterQuote{}, + positions: map[common.Hash]map[common.Address]morpho.PositionState{}, + }) + return m +} + +func (m *apiMonitor) snapshot() *snapshot { + return m.snap.Load() +} + +func (m *apiMonitor) name() string { return "api" } + +// candidates evaluates our tracked at-risk set at the auction price. RedStone's pushed positions are ignored. +func (m *apiMonitor) candidates(auction AuctionMessage, nowTs uint64) []evalItem { + return candidatesFromAuction(m.log, m.snapshot(), auction, nowTs) +} + +func quoteCollateralsFromSnapshot(snap *snapshot) []common.Address { + if snap == nil { + return nil + } + seen := make(map[common.Address]bool, len(snap.quotes)) + out := make([]common.Address, 0, len(snap.quotes)) + for id := range snap.quotes { + info, ok := snap.markets[id] + if !ok { + continue + } + coll := info.Params.CollateralToken + if coll == (common.Address{}) || seen[coll] { + continue + } + seen[coll] = true + out = append(out, coll) + } + return out +} + +func compactQuotes(in map[common.Hash]*AdapterQuote) map[common.Hash]AdapterQuote { + out := make(map[common.Hash]AdapterQuote, len(in)) + for id, q := range in { + if q != nil { + out[id] = *q + } + } + return out +} + +// run drives API snapshot refreshes until ctx is cancelled. +func (m *apiMonitor) run(ctx context.Context) { + tick := time.NewTicker(m.monitorPoll) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + m.refresh(ctx) + } + } +} + +func (m *apiMonitor) refresh(ctx context.Context) { + adapter, err := m.reader.readAdapterSnapshot(ctx, m.callback, m.adapter) + if err != nil { + m.log.Error(err, "API refresh skipped: adapter state unreadable") + return + } + + apiMarkets, err := m.api.DiscoverMarketData(ctx, m.chainID, []common.Address{adapter.loan}, adapter.redeemable) + if err != nil { + m.log.Error(err, "morpho API market refresh failed; keeping cache") + return + } + apiSnap := m.apiMarketSnapshot(apiMarkets, adapter.loan, adapter.redeemable, adapter.filler) + if len(apiSnap.markets) == 0 { + m.log.V(1).Info("morpho API market refresh returned no usable adapter markets") + return + } + + apiQuotes, err := m.reader.ReadAdapterQuotes(ctx, apiSnap.params, m.adapter, apiSnap.serve) + if err != nil { + m.log.Error(err, "adapter quote refresh failed; keeping cache") + return + } + quotes := compactQuotes(apiQuotes) + + ids := make([]common.Hash, 0, len(apiSnap.markets)) + for id := range apiSnap.markets { + ids = append(ids, id) + } + apiPositions, err := m.api.PositionsByMarket(ctx, ids, m.maxPositions, &m.maxHF) + if err != nil { + m.log.Error(err, "morpho API position refresh failed; keeping cache") + return + } + positions := apiPositionsSnapshot(apiPositions, apiSnap.markets) + + m.snap.Store(&snapshot{ + markets: apiSnap.markets, prices: apiSnap.prices, quotes: quotes, positions: positions, + block: apiSnap.block, blockTime: apiSnap.blockTime, + }) +} + +type apiMarketSnapshot struct { + markets map[common.Hash]MarketInfo + prices map[common.Hash]*big.Int + params map[common.Hash]abiMarketParams + serve map[common.Hash]bool + block uint64 + blockTime uint64 +} + +func (m *apiMonitor) apiMarketSnapshot(apiMarkets []morphoMarket, loan common.Address, redeemable []common.Address, filler bool) apiMarketSnapshot { + redeem := make(map[common.Address]bool, len(redeemable)) + for _, a := range redeemable { + redeem[a] = true + } + out := apiMarketSnapshot{ + markets: make(map[common.Hash]MarketInfo, len(apiMarkets)), + prices: make(map[common.Hash]*big.Int, len(apiMarkets)), + params: make(map[common.Hash]abiMarketParams, len(apiMarkets)), + serve: make(map[common.Hash]bool, len(apiMarkets)), + } + views := make([]apiMarketView, 0, len(apiMarkets)) + for _, apiMarket := range apiMarkets { + view, ok := marketInfoFromAPI(apiMarket) + if !ok || view.info.Params.LoanToken != loan || !redeem[view.info.Params.CollateralToken] { + continue + } + derived, err := deriveMarketID(view.info.Params) + if err != nil || derived != view.id { + m.log.V(1).Info("morpho API market id mismatch; dropping", "market", view.id.Hex()) + continue + } + views = append(views, view) + if view.block > out.block { + out.block = view.block + out.blockTime = view.blockTime + } + } + for _, view := range views { + if view.block != out.block { + m.log.V(1).Info("morpho API market block mismatch; dropping", + "market", view.id.Hex(), "wantBlock", out.block, "gotBlock", view.block) + continue + } + out.markets[view.id] = view.info + out.params[view.id] = view.info.Params + out.serve[view.id] = filler + if view.price != nil { + out.prices[view.id] = view.price + } + } + return out +} + +type apiMarketView struct { + id common.Hash + info MarketInfo + price *big.Int + block uint64 + blockTime uint64 +} + +func marketInfoFromAPI(m morphoMarket) (apiMarketView, bool) { + if m.MarketID == (common.Hash{}) || m.CollateralAsset == nil || m.State == nil { + return apiMarketView{}, false + } + lltv, ok := parseAPIBig(m.LLTV) + if !ok { + return apiMarketView{}, false + } + supplyAssets, ok := parseAPIBig(m.State.SupplyAssets) + if !ok { + return apiMarketView{}, false + } + supplyShares, ok := parseAPIBig(m.State.SupplyShares) + if !ok { + return apiMarketView{}, false + } + borrowAssets, ok := parseAPIBig(m.State.BorrowAssets) + if !ok { + return apiMarketView{}, false + } + borrowShares, ok := parseAPIBig(m.State.BorrowShares) + if !ok { + return apiMarketView{}, false + } + lastUpdate, ok := parseAPIUint64(m.State.Timestamp) + if !ok { + return apiMarketView{}, false + } + block, ok := parseAPIUint64(m.State.BlockNumber) + if !ok || block == 0 { + return apiMarketView{}, false + } + var price *big.Int + if m.State.Price != "" { + if price, ok = parseAPIBig(m.State.Price); !ok { + return apiMarketView{}, false + } + } + params := abiMarketParams{ + LoanToken: m.LoanAsset.Address, + CollateralToken: m.CollateralAsset.Address, + Oracle: m.Oracle, + Irm: m.IRM, + Lltv: lltv, + } + if params.LoanToken == (common.Address{}) || params.CollateralToken == (common.Address{}) || + params.Oracle == (common.Address{}) { + return apiMarketView{}, false + } + return apiMarketView{id: m.MarketID, price: price, block: block, blockTime: lastUpdate, info: MarketInfo{ + Params: params, + State: morpho.MarketState{ + TotalSupplyAssets: supplyAssets, + TotalSupplyShares: supplyShares, + TotalBorrowAssets: borrowAssets, + TotalBorrowShares: borrowShares, + LastUpdate: lastUpdate, + Fee: big.NewInt(0), + Lltv: lltv, + BorrowRatePerSec: big.NewInt(0), + }, + }}, true +} + +func apiPositionsSnapshot(apiPositions []morphoPosition, markets map[common.Hash]MarketInfo) map[common.Hash]map[common.Address]morpho.PositionState { + out := make(map[common.Hash]map[common.Address]morpho.PositionState) + for _, p := range apiPositions { + if _, ok := markets[p.MarketID]; !ok { + continue + } + pos, ok := positionStateFromAPI(p) + if !ok { + continue + } + if out[p.MarketID] == nil { + out[p.MarketID] = make(map[common.Address]morpho.PositionState) + } + out[p.MarketID][p.Borrower] = pos + } + return out +} + +func positionStateFromAPI(p morphoPosition) (morpho.PositionState, bool) { + if p.MarketID == (common.Hash{}) || p.Borrower == (common.Address{}) { + return morpho.PositionState{}, false + } + borrowShares, ok := parseAPIBig(p.BorrowShares) + if !ok { + return morpho.PositionState{}, false + } + collateral, ok := parseAPIBig(p.Collateral) + if !ok { + return morpho.PositionState{}, false + } + return morpho.PositionState{BorrowShares: borrowShares, Collateral: collateral}, true +} + +func parseAPIBig(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 parseAPIUint64(s string) (uint64, bool) { + n, ok := parseAPIBig(s) + if !ok || !n.IsUint64() { + return 0, false + } + return n.Uint64(), true +} + +func snapshotHasPositions(snap *snapshot) bool { + if snap == nil { + return false + } + for _, byBorrower := range snap.positions { + if len(byBorrower) > 0 { + return true + } + } + return false +} + +func (s *Solver) fresh(a AuctionMessage) (bool, string) { + skip := snapshotFreshForAuction(s.mon.snapshot(), a) + return skip == "", skip +} + +func snapshotFreshForAuction(snap *snapshot, auction AuctionMessage) string { + if !snapshotHasPositions(snap) { + return "" + } + if snap.block == 0 || snap.blockTime == 0 { + return skipStaleEpoch + } + auctionTs := auction.Timestamp / 1000 + if auctionTs <= 0 { + return "" + } + if uint64(auctionTs) > snap.blockTime+uint64(snapshotMaxAuctionLag/time.Second) { + return skipStaleEpoch + } + return "" +} diff --git a/internal/solvers/redstoneoev/morphoapi.go b/internal/solvers/redstoneoev/morphoapi.go new file mode 100644 index 00000000..a6b62826 --- /dev/null +++ b/internal/solvers/redstoneoev/morphoapi.go @@ -0,0 +1,334 @@ +package redstoneoev + +// morphoapi.go adapts generated Morpho GraphQL responses into OEV-local snapshot types. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "slices" + "strings" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/morphographql" + "github.com/symbioticfi/vault-solver/api/morphographql/scalars" +) + +// maxDiscoverMarkets bounds the candidate markets one discovery poll proposes (the `first` arg + a defensive +// truncation): a token pair has few real markets, so this only stops a misbehaving/compromised endpoint +// from flooding the local snapshot — far above any honest (loan, collateral) market count. +const maxDiscoverMarkets = 512 + +// maxMorphoRespBytes bounds the response body we read from the external endpoint (defence in depth atop the +// client Timeout): a misbehaving/compromised endpoint can't drive unbounded allocation in the discovery +// goroutine. The at-risk band for our market set is small; 8 MiB is far above any honest response. +const maxMorphoRespBytes = 8 << 20 + +// Live Morpho API request caps observed on 2026-06-26: `marketPositions(first:1001)` and +// `marketUniqueKey_in` with 101 ids both fail input validation. Keep each HTTP response small and page in +// the wrapper so solver config can express a larger logical cap. +const ( + maxPositionsPage = 1000 + maxPositionMarketIDs = 100 +) + +// morphoClient is the OEV-local adapter over the generated Morpho GraphQL binding. +type morphoClient struct { + gql graphql.Client +} + +type morphoMarket struct { + MarketID common.Hash + Oracle common.Address + IRM common.Address + LLTV string + LoanAsset morphoAsset + CollateralAsset *morphoAsset + State *morphoMarketState +} + +type morphoAsset struct { + Address common.Address +} + +type morphoMarketState struct { + BlockNumber string + BorrowAssets string + BorrowShares string + SupplyAssets string + SupplyShares string + Timestamp string + Price string +} + +type morphoPosition struct { + MarketID common.Hash + Borrower common.Address + HealthFactor *float64 + BorrowShares string + Collateral string +} + +func newMorphoClient(url string) *morphoClient { + hc := &http.Client{Timeout: 8 * time.Second} + return &morphoClient{ + gql: boundedGraphQLClient{url: url, hc: hc}, + } +} + +// DiscoverMarketData returns Morpho markets plus their latest indexed state for adapter-derived token +// pairs. Callers must still fail closed on malformed items and verify that the derived market id matches the +// returned id before using the data for execution. +func (a *morphoClient) DiscoverMarketData(ctx context.Context, chainID int64, loan, collateral []common.Address) ([]morphoMarket, error) { + if len(loan) == 0 || len(collateral) == 0 { + return nil, nil + } + data, err := morphographql.MorphoDiscoverMarkets(ctx, a.gql, lowerAddrs(loan), lowerAddrs(collateral), []int{int(chainID)}, maxDiscoverMarkets) + if err != nil { + return nil, err + } + out := make([]morphoMarket, 0, min(len(data.Markets.Items), maxDiscoverMarkets)) + for i := range data.Markets.Items { + if len(out) >= maxDiscoverMarkets { + break + } + m := morphoMarketFromDiscover(data.Markets.Items[i]) + if m.MarketID == (common.Hash{}) { + continue + } + out = append(out, m) + } + return out, nil +} + +func (a *morphoClient) PositionsByMarket(ctx context.Context, marketIDs []common.Hash, first int, maxHF *float64) ([]morphoPosition, error) { + if len(marketIDs) == 0 || first <= 0 { + return nil, nil + } + ids := make([]string, 0, len(marketIDs)) + for _, id := range marketIDs { + ids = append(ids, strings.ToLower(id.Hex())) + } + out := make([]morphoPosition, 0, min(first, maxPositionsPage)) + for start := 0; start < len(ids); start += maxPositionMarketIDs { + end := min(start+maxPositionMarketIDs, len(ids)) + page, err := a.positionsChunk(ctx, ids[start:end], first, maxHF) + if err != nil { + return nil, err + } + out = append(out, page...) + } + sortPositionsByRisk(out) + if len(out) > first { + out = out[:first] + } + return out, nil +} + +func (a *morphoClient) positionsChunk(ctx context.Context, ids []string, limit int, maxHF *float64) ([]morphoPosition, error) { + out := make([]morphoPosition, 0, min(limit, maxPositionsPage)) + for skip := 0; len(out) < limit; { + first := min(maxPositionsPage, limit-len(out)) + data, err := morphographql.MorphoPositionsByMarket(ctx, a.gql, ids, first, skip, maxHF) + if err != nil { + return nil, err + } + items := data.MarketPositions.Items + if len(items) == 0 { + break + } + for _, it := range items { + var state wirePositionState + if it.State != nil { + state = it.State + } + pos := morphoPositionFromWire(it.User.Address, it.Market.MarketId, it.HealthFactor, state) + if pos.MarketID == (common.Hash{}) || pos.Borrower == (common.Address{}) { + continue + } + out = append(out, pos) + } + if len(items) < first { + break + } + skip += len(items) + } + return out, nil +} + +func sortPositionsByRisk(pos []morphoPosition) { + slices.SortStableFunc(pos, func(a, b morphoPosition) int { + if a.HealthFactor != nil && b.HealthFactor != nil && *a.HealthFactor != *b.HealthFactor { + if *a.HealthFactor < *b.HealthFactor { + return -1 + } + return 1 + } + if a.HealthFactor == nil && b.HealthFactor != nil { + return 1 + } + if a.HealthFactor != nil && b.HealthFactor == nil { + return -1 + } + if c := a.MarketID.Cmp(b.MarketID); c != 0 { + return c + } + return a.Borrower.Cmp(b.Borrower) + }) +} + +// lowerAddrs maps addresses to their lowercase 0x hex form (the Morpho API matches addresses lowercased). +func lowerAddrs(addrs []common.Address) []string { + out := make([]string, len(addrs)) + for i, a := range addrs { + out[i] = strings.ToLower(a.Hex()) + } + return out +} + +type wireAsset interface { + GetAddress() string +} + +type wireMarketState interface { + GetBlockNumber() scalars.BigIntString + GetBorrowAssets() scalars.BigIntString + GetBorrowShares() scalars.BigIntString + GetSupplyAssets() scalars.BigIntString + GetSupplyShares() scalars.BigIntString + GetTimestamp() scalars.BigIntString + GetPrice() *scalars.BigIntString +} + +func morphoMarketFromDiscover(it morphographql.MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) morphoMarket { + var coll wireAsset + if it.CollateralAsset != nil { + coll = it.CollateralAsset + } + var state wireMarketState + if it.State != nil { + state = it.State + } + return morphoMarketFromWire(it.MarketId, it.OracleAddress, it.IrmAddress, it.Lltv.String(), &it.LoanAsset, coll, state) +} + +func morphoMarketFromWire(id, oracle, irm, lltv string, loan wireAsset, collateral wireAsset, state wireMarketState) morphoMarket { + m := morphoMarket{ + MarketID: common.HexToHash(id), + Oracle: common.HexToAddress(oracle), + IRM: common.HexToAddress(irm), + LLTV: lltv, + LoanAsset: morphoAssetFromWire(loan), + } + if collateral != nil { + c := morphoAssetFromWire(collateral) + m.CollateralAsset = &c + } + if state != nil { + m.State = morphoMarketStateFromWire(state) + } + return m +} + +func morphoAssetFromWire(a wireAsset) morphoAsset { + if a == nil { + return morphoAsset{} + } + return morphoAsset{Address: common.HexToAddress(a.GetAddress())} +} + +func morphoMarketStateFromWire(s wireMarketState) *morphoMarketState { + st := &morphoMarketState{ + BlockNumber: s.GetBlockNumber().String(), + BorrowAssets: s.GetBorrowAssets().String(), + BorrowShares: s.GetBorrowShares().String(), + SupplyAssets: s.GetSupplyAssets().String(), + SupplyShares: s.GetSupplyShares().String(), + Timestamp: s.GetTimestamp().String(), + } + if p := s.GetPrice(); p != nil { + st.Price = p.String() + } + return st +} + +type wirePositionState interface { + GetBorrowShares() scalars.BigIntString + GetCollateral() scalars.BigIntString +} + +func morphoPositionFromWire(user, market string, health *float64, state wirePositionState) morphoPosition { + pos := morphoPosition{ + MarketID: common.HexToHash(market), + Borrower: common.HexToAddress(user), + HealthFactor: health, + } + if state == nil { + return pos + } + pos.BorrowShares = state.GetBorrowShares().String() + pos.Collateral = state.GetCollateral().String() + return pos +} + +type boundedGraphQLClient struct { + url string + hc *http.Client +} + +// MakeRequest is genqlient's transport hook. It keeps the old fail-safe HTTP behavior while the query and +// response types come from generated code. +func (c boundedGraphQLClient) MakeRequest(ctx context.Context, req *graphql.Request, resp *graphql.Response) error { + body, err := json.Marshal(req) + if err != nil { + return errors.Errorf("morpho graphql: marshal request: %w", err) + } + reqCtx, cancel := context.WithTimeout(ctx, c.hc.Timeout) + defer cancel() + httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.url, bytes.NewReader(body)) + if err != nil { + return errors.Errorf("morpho graphql: build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + httpResp, err := c.hc.Do(httpReq) + if err != nil { + return errors.Errorf("morpho graphql: request: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + if httpResp.StatusCode != http.StatusOK { + return errors.Errorf("morpho graphql: status %d", httpResp.StatusCode) + } + + raw, err := io.ReadAll(io.LimitReader(httpResp.Body, maxMorphoRespBytes+1)) + if err != nil { + return errors.Errorf("morpho graphql: read response: %w", err) + } + if len(raw) > maxMorphoRespBytes { + return errors.New("morpho graphql: response too large") + } + if err := json.Unmarshal(raw, resp); err != nil { + return errors.Errorf("morpho graphql: decode response: %w", err) + } + if len(resp.Errors) > 0 { + return errors.Errorf("morpho graphql: graphql error: %s", resp.Errors[0].Message) + } + + var envelope struct { + Data *json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return errors.Errorf("morpho graphql: decode response envelope: %w", err) + } + if envelope.Data == nil || bytes.Equal(bytes.TrimSpace(*envelope.Data), []byte("null")) { + return errors.New("morpho graphql: response missing data") + } + return nil +} diff --git a/internal/solvers/redstoneoev/noncestore.go b/internal/solvers/redstoneoev/noncestore.go new file mode 100644 index 00000000..1f98a25c --- /dev/null +++ b/internal/solvers/redstoneoev/noncestore.go @@ -0,0 +1,34 @@ +package redstoneoev + +import "sync" + +// nonceStore issues strictly-ascending EXECUTOR_V6 nonces. The Executor requires nonce > +// nonces[signer] and only advances that on a settled (or failed) execution, so we track the last +// issued nonce in memory and reconcile it with the on-chain value (which can jump ahead after a +// settlement we didn't initiate the next bid for). See docs/OEV-PLAN.md §6.2. +type nonceStore struct { + mu sync.Mutex + issued uint64 // highest nonce handed out so far +} + +// reconcile raises the in-memory high-water mark to the on-chain nonce (called at boot and from the +// ops loop). Never lowers it. +func (n *nonceStore) reconcile(onchain uint64) { + n.mu.Lock() + defer n.mu.Unlock() + if onchain > n.issued { + n.issued = onchain + } +} + +// next returns the next nonce to sign: strictly greater than both the on-chain nonce and any nonce +// already issued this session. +func (n *nonceStore) next(onchain uint64) uint64 { + n.mu.Lock() + defer n.mu.Unlock() + if onchain > n.issued { + n.issued = onchain + } + n.issued++ + return n.issued +} diff --git a/internal/solvers/redstoneoev/operationdata.go b/internal/solvers/redstoneoev/operationdata.go new file mode 100644 index 00000000..a62a5b9d --- /dev/null +++ b/internal/solvers/redstoneoev/operationdata.go @@ -0,0 +1,160 @@ +package redstoneoev + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-errors/errors" +) + +// LiquidationLeg is one solver-selected liquidation cap. The callback receives only the signed fields +// encoded by callbackLeg; SwapAmountOut is a solver-only estimate for selection and gas prediction. +type LiquidationLeg struct { + MarketId common.Hash + Borrower common.Address + MaxSeizeAssets *big.Int + MinProfit *big.Int + SwapAmountOut *big.Int +} + +type operationAuth struct { + AuctionKey common.Hash + BidAmount *big.Int + MinBundleProfit *big.Int +} + +type callbackLeg struct { + MarketId common.Hash + Borrower common.Address + MaxSeizeAssets *big.Int + MinProfit *big.Int +} + +type operationData struct { + Auth operationAuth + Legs []callbackLeg + AuthSig []byte +} + +var ( + operationDataArgs = abi.Arguments{{Type: mustOperationDataType()}} + callbackLegArrayArgs = abi.Arguments{{Type: mustCallbackLegArrayType()}} + authDigestArgs = abi.Arguments{ + {Type: mustType("bytes32")}, + {Type: mustType("uint256")}, + {Type: mustType("address")}, + {Type: mustType("address")}, + {Type: mustType("bytes32")}, + {Type: mustType("uint256")}, + {Type: mustType("uint256")}, + {Type: mustType("bytes32")}, + } + authDomain = crypto.Keccak256Hash([]byte("SYMBIOTIC_OEV_AUTH_V2")) +) + +// EncodeOperationData ABI-encodes the callback payload committed by the RedStone EXECUTOR_V6 signature. +func EncodeOperationData(auth operationAuth, legs []LiquidationLeg, authSig []byte) ([]byte, error) { + if len(legs) == 0 { + return nil, errors.New("operationData: no legs") + } + if auth.BidAmount == nil || auth.MinBundleProfit == nil || auth.MinBundleProfit.Sign() <= 0 { + return nil, errors.New("operationData: invalid auth") + } + for i, leg := range legs { + if leg.MinProfit == nil || leg.MinProfit.Sign() < 0 { + return nil, errors.Errorf("operationData: invalid leg %d minProfit", i) + } + } + op := operationData{Auth: auth, Legs: encodeLegs(legs), AuthSig: authSig} + enc, err := operationDataArgs.Pack(op) + if err != nil { + return nil, errors.Errorf("encode operationData: %w", err) + } + return enc, nil +} + +func CallbackAuthDigest(chainID *big.Int, callback, executor common.Address, auth operationAuth, legs []LiquidationLeg) (common.Hash, error) { + legsHash, err := encodedLegsHash(legs) + if err != nil { + return common.Hash{}, err + } + enc, err := authDigestArgs.Pack(authDomain, chainID, callback, executor, auth.AuctionKey, auth.BidAmount, auth.MinBundleProfit, legsHash) + if err != nil { + return common.Hash{}, errors.Errorf("encode callback auth digest: %w", err) + } + return crypto.Keccak256Hash(enc), nil +} + +func encodedLegsHash(legs []LiquidationLeg) (common.Hash, error) { + enc, err := callbackLegArrayArgs.Pack(encodeLegs(legs)) + if err != nil { + return common.Hash{}, errors.Errorf("encode callback auth legs: %w", err) + } + return crypto.Keccak256Hash(enc), nil +} + +func encodeLegs(legs []LiquidationLeg) []callbackLeg { + out := make([]callbackLeg, len(legs)) + for i, leg := range legs { + out[i] = callbackLeg{ + MarketId: leg.MarketId, + Borrower: leg.Borrower, + MaxSeizeAssets: leg.MaxSeizeAssets, + MinProfit: leg.MinProfit, + } + } + return out +} + +func auctionKeyHash(a AuctionMessage) common.Hash { + return crypto.Keccak256Hash([]byte(a.dedupKey())) +} + +func legsWithProfitFloors(legs []LiquidationLeg, gas gasPrediction, gasPrice, rate *big.Int) []LiquidationLeg { + out := make([]LiquidationLeg, len(legs)) + copy(out, legs) + for i := range out { + route := gasRouteUnknown + if i < len(gas.Routes) { + route = gas.Routes[i] + } + units := gasUnitsForRoute(route) + out[i].MinProfit = nativeToLoan(gasCostNative(units, gasPrice), rate) + } + return out +} + +func mustOperationDataType() abi.Type { + t, err := abi.NewType("tuple", "", []abi.ArgumentMarshaling{ + {Name: "auth", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "auctionKey", Type: "bytes32"}, + {Name: "bidAmount", Type: "uint256"}, + {Name: "minBundleProfit", Type: "uint256"}, + }}, + {Name: "legs", Type: "tuple[]", Components: callbackLegComponents()}, + {Name: "authSig", Type: "bytes"}, + }) + if err != nil { + panic("redstoneoev: build OperationData type: " + err.Error()) + } + return t +} + +func mustCallbackLegArrayType() abi.Type { + t, err := abi.NewType("tuple[]", "", callbackLegComponents()) + if err != nil { + panic("redstoneoev: build LiquidationLeg[] type: " + err.Error()) + } + return t +} + +func callbackLegComponents() []abi.ArgumentMarshaling { + return []abi.ArgumentMarshaling{ + {Name: "marketId", Type: "bytes32"}, + {Name: "borrower", Type: "address"}, + {Name: "maxSeizeAssets", Type: "uint256"}, + {Name: "minProfit", Type: "uint256"}, + } +} diff --git a/internal/solvers/redstoneoev/rate.go b/internal/solvers/redstoneoev/rate.go new file mode 100644 index 00000000..c8c160a1 --- /dev/null +++ b/internal/solvers/redstoneoev/rate.go @@ -0,0 +1,55 @@ +package redstoneoev + +// rate.go holds loan↔ETH rate resolution and loan/native conversions for profitability gates and bid sizing. + +import ( + "math/big" + + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// composeLoanPerEth derives loanPerEth (loan base units per 1 ETH) from two Chainlink-style oracle +// answers — ethUsd (ETH/USD, ethFeedDec decimals) and loanUsd (loan/USD, loanFeedDec decimals) — scaled +// to the loan token's own decimals: +// +// loanPerEth = ethUsd × 10^(loanDec + loanFeedDec) / (loanUsd × 10^ethFeedDec) +// +// e.g. ETH=$2500, USDC=$1, both feeds 8-dec, loanDec=6 → 2500e8 × 1e6 × 1e8 / (1e8 × 1e8) = 2500e6. +// Returns nil on any non-positive input so callers fail closed. +func composeLoanPerEth(ethUsd, loanUsd *big.Int, ethFeedDec, loanFeedDec, loanDec int) *big.Int { + if ethUsd == nil || loanUsd == nil || ethUsd.Sign() <= 0 || loanUsd.Sign() <= 0 { + return nil + } + num := new(big.Int).Mul(ethUsd, chain.Exp10(loanDec+loanFeedDec)) + den := new(big.Int).Mul(loanUsd, chain.Exp10(ethFeedDec)) + rate := new(big.Int).Quo(num, den) + if rate.Sign() <= 0 { + return nil + } + return rate +} + +// hasRateSource reports whether a loan↔ETH rate source is configured. Live bidding requires one because +// gas and bid are native-denominated settlement costs while profit is measured in the loan token. +func (c *Config) hasRateSource() bool { + return c.LoanEthFeed != nil +} + +// loanToNative converts loan-token base units to native token base units at the loanPerEth rate, rounding +// down. It returns 0 when no positive rate is available. +func loanToNative(loan, rate *big.Int) *big.Int { + if rate == nil || rate.Sign() <= 0 || loan == nil { + return new(big.Int) + } + return morpho.MulDivDown(loan, morpho.Wad, rate) +} + +// nativeToLoan converts native token base units to loan-token base units at loanPerEth, rounding up so +// cost floors stay conservative. +func nativeToLoan(native, rate *big.Int) *big.Int { + if rate == nil || rate.Sign() <= 0 || native == nil { + return new(big.Int) + } + return morpho.MulDivUp(native, rate, morpho.Wad) +} diff --git a/internal/solvers/redstoneoev/reservations.go b/internal/solvers/redstoneoev/reservations.go new file mode 100644 index 00000000..8fdce371 --- /dev/null +++ b/internal/solvers/redstoneoev/reservations.go @@ -0,0 +1,147 @@ +package redstoneoev + +// reservations.go holds the in-flight-bid headroom reservation subsystem and the auction-id dedup ring. + +import ( + "math/big" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// positionKey identifies a borrower position (one Morpho market + borrower) — the unit a bid liquidates. +type positionKey struct { + market common.Hash + borrower common.Address +} + +// reservedBid is one sent-but-not-yet-resolved bid's commitment against cached headroom: payBid native, +// predicted Executor-deposit gas debit, signed nonce, send time, and the positions it liquidates. +type reservedBid struct { + bidNative *big.Int + gasNative *big.Int + nonce uint64 + at time.Time + positions []positionKey + auctionID string + gasUnits uint64 + gasRoutes string +} + +// reservationTTL is only a fallback for missed auction/liquidation result frames. Normal release is +// event-driven: a lost auction-result or our liquidation-result frees the bid immediately, while a won bid +// without a result stays pinned long enough for delayed settlement/nonce reconciliation. +const reservationTTL = 5 * time.Minute + +type inFlightState struct { + positions map[positionKey]bool + bidNative *big.Int + gasNative *big.Int +} + +// inFlightSnapshot returns, in ONE pass under resMu, everything buildBid needs about sent-but-unresolved +// bids: the (market,borrower) set, the reserved callback payBid native, and the reserved Executor-deposit +// gas debit. +func (s *Solver) inFlightSnapshot() inFlightState { + s.resMu.Lock() + defer s.resMu.Unlock() + out := inFlightState{bidNative: new(big.Int), gasNative: new(big.Int)} + if len(s.res) > 0 { + out.positions = make(map[positionKey]bool, len(s.res)) + } + for _, r := range s.res { + out.bidNative.Add(out.bidNative, orZero(r.bidNative)) + out.gasNative.Add(out.gasNative, orZero(r.gasNative)) + for _, p := range r.positions { + out.positions[p] = true + } + } + return out +} + +// reserve records the headroom a just-sent bid commits: bid native, predicted gas debit from +// the Executor deposit, and the positions it liquidates. +func (s *Solver) reserve(bidNative, gasNative *big.Int, nonce uint64, now time.Time, positions []positionKey, auctionID string, gas gasPrediction) { + s.resMu.Lock() + defer s.resMu.Unlock() + s.res = append(s.res, reservedBid{ + bidNative: orZero(bidNative), + gasNative: orZero(gasNative), + nonce: nonce, + at: now, + positions: positions, + auctionID: auctionID, + gasUnits: gas.Units, + gasRoutes: gasRoutesString(gas.Routes), + }) +} + +func (s *Solver) reservationByAuction(id string) (reservedBid, bool) { + if id == "" { + return reservedBid{}, false + } + s.resMu.Lock() + defer s.resMu.Unlock() + for _, r := range s.res { + if r.auctionID == id { + return r, true + } + } + return reservedBid{}, false +} + +func (s *Solver) releaseReservationByAuction(id string) { + if id == "" { + return + } + s.resMu.Lock() + defer s.resMu.Unlock() + s.res = slices.DeleteFunc(s.res, func(r reservedBid) bool { return r.auctionID == id }) +} + +// pruneReservations frees a reservation once its bid resolves: when nonce <= the on-chain nonce (the bid +// won and settled — a pending bid is signed with nonce = on-chain + 1, so settlement sets the on-chain nonce +// to exactly the consumed bid's, and `<=` releases precisely then), or once it has aged past reservationTTL. +// Still-pending bids stay pinned. +func (s *Solver) pruneReservations(onChainNonce uint64, now time.Time) { + s.resMu.Lock() + defer s.resMu.Unlock() + s.res = slices.DeleteFunc(s.res, func(r reservedBid) bool { + return r.resolved(onChainNonce, now) + }) +} + +func (r reservedBid) resolved(onChainNonce uint64, now time.Time) bool { + return r.nonce <= onChainNonce || now.Sub(r.at) > reservationTTL +} + +// maxSeenAuctions bounds the de-dup set (insertion-ordered eviction); ample for the auction cadence. +const maxSeenAuctions = 1024 + +// seenAuctions is a bounded, insertion-ordered de-dup set for auction ids: a re-subscribe on reconnect can +// replay a frame, and bidding twice for one auction burns a second nonce + reserves a second headroom. +// Touched only by the single WS read goroutine (handleMessage), so it needs no lock. +type seenAuctions struct { + set map[string]struct{} + order []string + cap int +} + +func newSeenAuctions(capacity int) *seenAuctions { + return &seenAuctions{set: make(map[string]struct{}, capacity), cap: capacity} +} + +// seen reports whether id was already processed; if not, it records it (evicting the oldest past cap). +func (s *seenAuctions) seen(id string) bool { + if _, ok := s.set[id]; ok { + return true + } + if len(s.order) >= s.cap { + delete(s.set, s.order[0]) + s.order = s.order[1:] + } + s.set[id] = struct{}{} + s.order = append(s.order, id) + return false +} diff --git a/internal/solvers/redstoneoev/sizing.go b/internal/solvers/redstoneoev/sizing.go new file mode 100644 index 00000000..44b89739 --- /dev/null +++ b/internal/solvers/redstoneoev/sizing.go @@ -0,0 +1,174 @@ +package redstoneoev + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// Candidate is a position to evaluate. The Morpho worker (our independently-tracked at-risk set) is the +// SOLE position source — the auction frame supplies prices only (docs/OEV-PLAN.md §3.1). sizeLeg (below) +// is the single shared decision/sizing path. +type Candidate struct { + MarketID common.Hash + Borrower common.Address + Market MarketInfo + Position morpho.PositionState +} + +// AdapterQuote is the LiquidLane adapter's redemption terms for a market's collateral: the discounted +// sell rate (getMaxRate = the adapter's oracle price × (1 − curator minDiscount), 1e18-scaled) and +// the token decimals needed to convert it to a loan-token amount. This is the price at which we +// actually offload the seized RWA into the vault — distinct from the Morpho market price that drives +// the liquidation itself. +type AdapterQuote struct { + MaxRate *big.Int // 1e18-scaled, minDiscount already applied + MaxAssets *big.Int // getMaxAssets: cap on swap output (loan units) before the adapter reverts + + // LoanScale/CollScale are 10^loanDec / 10^collDec (the vault asset / collateral token decimals), + // precomputed once when the quote is built (always — see buildQuote and the tests' newQuote) so the + // per-leg hot path reads them directly instead of recomputing big.Int.Exp. Invariant: both are + // non-nil on every AdapterQuote. + LoanScale *big.Int + CollScale *big.Int +} + +// partialSeizeFractionBps is the fixed fallback when full-collateral liquidations are disabled. +const partialSeizeFractionBps = 9000 + +// SizingParams controls liquidation sizing (from config). When full liquidation is allowed, a leg targets +// all borrower collateral; otherwise it targets a fixed partial seize. The target is still clamped down by +// the borrower's debt and the adapter's getMaxAssets redemption liquidity. Profit is linear in seize, so +// the full mode captures bad-debt opportunities instead of deliberately leaving the last collateral slice. +type SizingParams struct { + AllowFullLiquidation bool // true => target all collateral; false => fixed partialSeizeFractionBps + SwapHaircutBps int // EXTRA safety margin on the adapter's already-discounted output (slippage/staleness) +} + +// swapOutFor is the loan-token we receive for selling `collIn` of seized collateral through quote q, at the +// adapter's discounted rate minus the extra safety haircut — mirrors the adapter's getAmountOut(getMaxRate): +// collIn × maxRate × 10^loanDec / (1e18 × 10^collDec), then × (1 − haircut). The RFQ solver replicates this +// same adapter formula in rfq/strategy.go amountOutForRate — keep both in sync (not unified: a shared helper +// would take several same-type big.Int args, a swap-footgun for fund pricing). +func swapOutFor(collIn *big.Int, q AdapterQuote, haircutBps int) *big.Int { + adapterOut := morpho.MulDivDown(new(big.Int).Mul(collIn, q.MaxRate), q.LoanScale, new(big.Int).Mul(morpho.Wad, q.CollScale)) + out := morpho.MulDivDown(adapterOut, big.NewInt(int64(10_000-haircutBps)), big.NewInt(10_000)) + // swapAmountOut is a MIN-out. The adapter recomputes its InvalidSwapRate ceiling with a different nested + // rounding (floor getAmountOut, THEN apply the curator discount) than our getMaxRate-derived value (the + // discount is pre-floored onto the oracle price), so ours can land 1 base unit above the ceiling when the + // haircut is ~0 → InvalidSwapRate revert. Shave one unit so the requested min-out never exceeds the + // on-chain ceiling (review K5); negligible vs leg profit, and only binds at a near-zero haircut. + if out.Sign() > 0 { + out.Sub(out, big.NewInt(1)) + } + return out +} + +// collForBudget is the inverse of swapOutFor: the most collateral whose swapOutFor(...) stays within +// `budget` (loan-token / getMaxAssets units), so an exit sized to it never asks the vault for more than its +// remaining redemption liquidity (which would revert InsufficientAllocate). The single flooring division +// guarantees swapOutFor(result) ≤ budget. Returns 0 when the quote can't price an exit. SwapHaircutBps is +// config-validated to [0, 10000), so 10000−haircut > 0. +func collForBudget(budget *big.Int, q AdapterQuote, haircutBps int) *big.Int { + h := int64(10_000 - haircutBps) + if h <= 0 || q.MaxRate == nil || q.MaxRate.Sign() <= 0 { + return new(big.Int) + } + num := new(big.Int).Mul(morpho.Wad, q.CollScale) + num.Mul(num, big.NewInt(10_000)) + den := new(big.Int).Mul(q.MaxRate, q.LoanScale) + den.Mul(den, big.NewInt(h)) + if den.Sign() == 0 { + return new(big.Int) + } + return morpho.MulDivDown(budget, num, den) +} + +// sizeLeg sizes ONE liquidation leg for candidate c, selling its WHOLE seizure through the single +// configured adapter (quote q) in one swap. It targets either all collateral or the fixed partial seize, +// CLAMPED by the borrower's full debt (so a small-debt / large-collateral position can't over-seize and +// revert the Morpho borrowShares underflow) AND by the adapter's getMaxAssets redemption liquidity (so the +// swap can't ask for more than the vault can allocate and revert InsufficientAllocate). swapAmountOut is +// the min loan-token out for the whole seize at the adapter's discounted getMaxRate minus the safety +// haircut. +// +// Returns the leg (one swap, SwapAmountOut set) and its gross loan profit (swapOut − repaid). ok=false when +// the position can't liquidate profitably here — including the contract's own guards: swapAmountOut must +// EXCEED the repayment. Bundle and gas economics are applied later by bundle selection and operationData. +func sizeLeg(c Candidate, price *big.Int, q AdapterQuote, accrued *big.Int, sp SizingParams) (LiquidationLeg, *big.Int, bool) { + m, p := c.Market.State, c.Position + if price == nil || price.Sign() <= 0 { + return LiquidationLeg{}, nil, false + } + if q.MaxRate == nil || q.MaxRate.Sign() <= 0 { + return LiquidationLeg{}, nil, false // can't price the exit + } + if !morpho.IsLiquidatableAt(p, price, m.Lltv, accrued, m.TotalBorrowShares) { + return LiquidationLeg{}, nil, false + } + target := targetSeize(p.Collateral, sp.AllowFullLiquidation) + if target.Sign() <= 0 { + return LiquidationLeg{}, nil, false + } + // LiquidationIncentiveFactor depends only on the market's lltv, so compute it ONCE here and feed it to + // both the full-debt clamp and the repayment quote (each recomputed it per leg before) — provably the + // same value. + lif := morpho.LiquidationIncentiveFactor(m.Lltv) + // Clamp the seize so the implied repayment never exceeds the borrower's debt. The leg sets MaxSeizeAssets + // with RepaidShares=0, so Morpho derives repaidShares from the seize and reverts (borrowShares underflow) + // once the implied repayment would exceed the outstanding debt — which happens whenever the target + // collateral is worth more debt than the borrower carries (small debt vs large collateral). maxSeize is + // the inverse forward-map at the full-debt point (rounded down), so a full liquidation clamps here and + // can't round up past the debt. maxSeize can floor to 0 for a dust position (debt worth < ~1 collateral + // unit); clamping target to 0 then returns ok=false below, so we skip it rather than submit a + // guaranteed-revert over-seize (do NOT guard on maxSeize > 0). + if maxSeize := morpho.MaxSeizeForFullDebt(p.BorrowShares, price, lif, accrued, m.TotalBorrowShares); target.Cmp(maxSeize) > 0 { + target = maxSeize + } + // Clamp the seize by the adapter's getMaxAssets redemption liquidity: collForBudget is the most + // collateral whose swapOut stays within MaxAssets, so the requested min-out never exceeds what the vault + // can allocate (InsufficientAllocate). nil/0 ⇒ uncapped (unknown liquidity). + if q.MaxAssets != nil && q.MaxAssets.Sign() > 0 { + if fit := collForBudget(q.MaxAssets, q, sp.SwapHaircutBps); fit.Cmp(target) < 0 { + target = fit + } + } + if target.Sign() <= 0 { + return LiquidationLeg{}, nil, false + } + swapOut := swapOutFor(target, q, sp.SwapHaircutBps) + if swapOut.Sign() <= 0 { + return LiquidationLeg{}, nil, false + } + repaid := morpho.RepaidAssetsForSeizeAt(target, price, lif, accrued, m.TotalBorrowShares) + if swapOut.Cmp(repaid) <= 0 { + return LiquidationLeg{}, nil, false // proceeds can't cover repayment after discount + haircut + } + profit := new(big.Int).Sub(swapOut, repaid) // > 0 here + leg := LiquidationLeg{ + MarketId: c.MarketID, + Borrower: c.Borrower, + MaxSeizeAssets: target, + SwapAmountOut: swapOut, + } + return leg, profit, true +} + +func targetSeize(collateral *big.Int, allowFull bool) *big.Int { + if collateral == nil { + return new(big.Int) + } + if allowFull { + return new(big.Int).Set(collateral) + } + return morpho.MulDivDown(collateral, big.NewInt(partialSeizeFractionBps), big.NewInt(10_000)) +} + +func orZero(n *big.Int) *big.Int { + if n == nil { + return big.NewInt(0) + } + return new(big.Int).Set(n) +} diff --git a/internal/solvers/redstoneoev/solver.go b/internal/solvers/redstoneoev/solver.go new file mode 100644 index 00000000..0ed578b8 --- /dev/null +++ b/internal/solvers/redstoneoev/solver.go @@ -0,0 +1,729 @@ +// Package redstoneoev implements the RedStone Atom OEV liquidation solver: it subscribes to OEV +// auctions over WebSocket, computes liquidatable Morpho Blue positions over our own independently-tracked +// at-risk set (Morpho API, or Sepolia testMonitor seeds; the auction frame supplies prices only), signs EXECUTOR_V6 bids, +// and replies with solve payloads that settle through an on-chain IOperationCallback contract (the single +// LiquidLane adapter). It self-registers via init(). See docs/OEV-PLAN.md. +package redstoneoev + +import ( + "context" + "encoding/json" + "math/big" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/morpho" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +// Name is the registry key that selects this solver from config. +const Name = "redstone-oev" + +// minDeposit is the Executor's MIN_DEPOSIT (0.00001 ETH) — below this, settlement reverts (§6.2). +var minDeposit = big.NewInt(1e13) + +// skipNoLegs is the bounded skip reason for "no liquidatable leg this auction" (a metric label). +const skipNoLegs = "no_legs" + +// skipGasUnprofitable is the bounded skip reason for bundles whose loan profit does not cover estimated +// settlement gas plus the configured min-profit margin. +const skipGasUnprofitable = "gas_unprofitable" + +// skipStaleEpoch is the bounded skip reason for missing or stale monitor snapshot epochs. +const skipStaleEpoch = "stale_epoch" + +const ( + skipDepositLow = "deposit_low" + skipCallbackBalance = "callback_balance" +) + +//nolint:gochecknoinits // self-registration with the solver framework is the intended plugin pattern. +func init() { + solver.Register(Name, factory) +} + +// Solver is the RedStone OEV bidding strategy. +type Solver struct { + cfg *Config + deps solver.Deps + chainID *big.Int + dryRun bool // OEV_DRY_RUN: observe mode — sign + log would-bids, never send (env knob, not config) + reader *reader + mon monitorSource // Morpho market/position monitor — the single OEV opportunity source + nonces *nonceStore + breaker *breaker + metrics *metrics + ws *wsClient + seen *seenAuctions // de-dup of already-processed auction ids (WS-goroutine-only) + log logr.Logger + + state stateCache // cached executor accounting + callback balance, refreshed by the ops loop + + // resMu guards res: the per-bid reservation of payBid native and predicted gas debit committed by bids + // already SENT but not yet reflected on-chain. buildBid debits these reservations from the cached callback + // balance and Executor deposit so bids inside one ops-poll window cannot over-commit either funding pot. + // pruneReservations frees a bid once it RESOLVES — its nonce fell below the on-chain nonce (submitted → + // settled or reverted; the fresh read reflects it) or it aged past reservationTTL as a last-resort cleanup + // for missed result frames — so fresh unresolved bids keep pinning headroom. + resMu sync.Mutex + res []reservedBid +} + +func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { + cfg, err := parseConfig(raw) + if err != nil { + return nil, err + } + apiKey := os.Getenv(cfg.APIKeyEnv) + if apiKey == "" { + return nil, errors.Errorf("%s: ws api key env %q is empty", Name, cfg.APIKeyEnv) + } + // Dev/test knobs come from env (read at point of use), never config: the on-chain price basis and the + // dry-run observe mode. Malformed values fail closed here. + onchainPrice, err := onchainPriceForTestEnv() + if err != nil { + return nil, errors.Errorf("%s: %w", Name, err) + } + testMonitor, err := testMonitorEnv() + if err != nil { + return nil, errors.Errorf("%s: %w", Name, err) + } + dryRun, err := dryRunEnv() + if err != nil { + return nil, errors.Errorf("%s: %w", Name, err) + } + if !cfg.hasRateSource() { + return nil, errors.Errorf("%s: loanEthFeed is required so bundle profit can be gated after gas", Name) + } + chainID := deps.Chain.ChainID() + if !chainID.IsInt64() || chainID.Sign() <= 0 { + return nil, errors.Errorf("%s: chain id %s out of supported range", Name, chainID) + } + log := deps.Log.WithName(Name) + rdr := newReader(deps.Chain, log) + var mon monitorSource + if testMonitor { + mon, err = newTestMonitor(rdr, log, cfg) + if err != nil { + return nil, errors.Errorf("%s: %w", Name, err) + } + } else { + if cfg.MorphoAPIURL == "" { + return nil, errors.Errorf("%s: morphoApiUrl is required unless %s=true", Name, envTestMonitor) + } + mon = newAPIMonitor(rdr, log, cfg, chainID.Int64()) + } + if onchainPrice && !testMonitor { + return nil, errors.Errorf("%s: %s requires %s=true", Name, envOnchainPrice, envTestMonitor) + } + + var mx *metrics + if deps.Metrics != nil { + if mx, err = newMetrics(deps.Metrics.Registerer()); err != nil { + return nil, err + } + } + + s := &Solver{ + cfg: cfg, + deps: deps, + chainID: chainID, + dryRun: dryRun, + reader: rdr, + mon: mon, + nonces: &nonceStore{}, + breaker: newBreaker(cfg.BreakerMaxFailures, cfg.BreakerWindow), + metrics: mx, + seen: newSeenAuctions(maxSeenAuctions), + log: log, + } + topics := []string{"oev/liquidations", "oev/feeds", "oev/notify/" + strings.ToLower(cfg.Callback.Hex())} + s.ws = newWSClient(wsConfig{URL: cfg.WSURL, APIKey: apiKey, Topics: topics}, log, s.handleMessage) + return s, nil +} + +// Name identifies the solver. +func (s *Solver) Name() string { return Name } + +// Run warms the caches, starts the monitor + ops loops, and serves the WS auction stream until ctx +// is cancelled. +func (s *Solver) Run(ctx context.Context) error { + s.log.Info("starting", + "callback", s.cfg.Callback.Hex(), "executor", s.cfg.Executor.Hex(), + "adapter", s.cfg.Adapter.Hex(), "monitor", s.mon.name(), + "dryRun", s.dryRun, "signer", s.deps.Signer.Address().Hex()) + s.mon.refresh(ctx) // seed market quotes before state; the gas predictor derives its collateral set from them + s.refreshState(ctx) // seed nonce + deposit + callback balance before any bid + + // Start the background loops and join them on shutdown so no goroutine outlives Run (and races + // deps teardown). The monitor runs its own market/position refresh loops; the WS client blocks + // until ctx is cancelled; the ops loop keys off the same ctx. + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); s.mon.run(ctx) }() + go func() { defer wg.Done(); s.opsLoop(ctx) }() + err := s.ws.Run(ctx) + wg.Wait() + return err +} + +// opsLoop periodically refreshes the Executor accounting (nonce/deposit/locked) used for pre-bid +// checks and nonce reconciliation. +func (s *Solver) opsLoop(ctx context.Context) { + t := time.NewTicker(s.cfg.OpsPoll) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.refreshState(ctx) + } + } +} + +// refreshState reads the signer's Executor accounting and the callback's native balance into the cache, +// and reconciles the nonce high-water mark. Run by the ops loop so deposit/nonce/balance stay fresh. +// +// The Executor-state read is the load-bearing one: nonce reconciliation, reservation pruning, and the +// deposit-low alarm all derive from st alone. A transient BalanceAt failure must NOT skip that bookkeeping +// (it would leave reservations pinning headroom and the nonce high-water mark stale). So on a balance read +// failure we keep the previously-cached callback balance (don't overwrite with a bad value) but STILL run +// the executor-derived bookkeeping (applyExecutorState). +func (s *Solver) refreshState(ctx context.Context) { + head, herr := s.latestHeadState(ctx) + if herr != nil { + s.log.Error(herr, "read block for state refresh failed; keeping cache") + return + } + epoch := newReadEpoch(head.Number, time.Now()) + st, err := s.reader.ReadExecutorState(ctx, s.cfg.Executor, s.deps.Signer.Address()) + if err != nil { + s.log.Error(err, "read executor state failed; keeping cache") + return + } + // Callback native balance: on a read failure keep the last good cached value rather than overwriting it + // (an absent cache stays absent — pre-bid funding then fails closed on state_unknown). + bal, berr := s.deps.Chain.BalanceAt(ctx, s.cfg.Callback, nil) + if berr != nil { + s.log.Error(berr, "read callback balance failed; keeping last cached balance") + if prev, ok := s.state.load(); ok { + bal = prev.CallbackNative + } else { + bal = nil + } + } + // Publish a usable state only with a callback balance in hand. Storing a nil balance would make load() + // report ready, and the callback_balance gate's Sub(CallbackNative, …) would nil-deref and crash the WS + // goroutine. With no balance (read failed AND no prior cache) keep the cache absent so pre-bid funding + // fails closed on state_unknown — as the comment above intends. applyExecutorState still runs the + // balance-independent bookkeeping (reservations/nonce/deposit) regardless. + if bal != nil { + rate := s.reader.ReadLoanEthRate(ctx, s.cfg.Adapter, s.cfg.LoanEthFeed, epoch.At) + gasState, gerr := s.reader.ReadGasPredictorState(ctx, s.cfg.Adapter, quoteCollateralsFromSnapshot(s.mon.snapshot())) + if gerr != nil { + s.log.Error(gerr, "read gas predictor state failed; keeping last cached predictor state") + if prev, ok := s.state.load(); ok { + gasState = prev.Gas + } + } + if !s.epochStillCurrent(ctx, epoch, "state") { + return + } + s.state.store(cachedState{ + Exec: st, CallbackNative: bal, Rate: rate, Gas: gasState, + GasLimit: head.GasLimit, + }) + } + s.applyExecutorState(st, bal, epoch.At) +} + +type latestHeadState struct { + Number uint64 + GasLimit uint64 +} + +func (s *Solver) latestHeadState(ctx context.Context) (latestHeadState, error) { + header, err := s.deps.Chain.HeaderByNumber(ctx, nil) + if err == nil { + if header == nil || header.Number == nil || !header.Number.IsUint64() { + return latestHeadState{}, errors.New("latest header missing uint64 block number") + } + return latestHeadState{Number: header.Number.Uint64(), GasLimit: header.GasLimit}, nil + } + head, berr := s.deps.Chain.BlockNumber(ctx) + if berr != nil { + return latestHeadState{}, err + } + s.log.Error(err, "read latest header failed; using configured gas price cap") + return latestHeadState{Number: head}, nil +} + +func (s *Solver) epochStillCurrent(ctx context.Context, epoch readEpoch, label string) bool { + head, err := s.deps.Chain.BlockNumber(ctx) + if err != nil { + s.log.Error(err, "read block after "+label+" refresh failed; keeping cache") + return false + } + if head != epoch.Block { + s.log.V(1).Info("refresh crossed block boundary; keeping cache", "phase", label, "startBlock", epoch.Block, "endBlock", head) + return false + } + return true +} + +// rate resolves the loan↔ETH rate (loan base units per 1 ETH) from the last oracle refresh. +func (s *Solver) rate(st cachedState) *big.Int { + if st.Rate != nil && st.Rate.Sign() > 0 { + return st.Rate + } + return nil +} + +// applyExecutorState runs the bookkeeping derived purely from the Executor state read (nonce + deposit): +// reservation pruning, nonce high-water reconciliation, the balance gauges, and the deposit-low alarm. +// Split out of refreshState so it runs whenever ReadExecutorState succeeds — independent of the BalanceAt +// outcome. `bal` (the callback native, possibly the last cached value or nil) drives only the balance gauge. +// No I/O → directly unit-testable. +func (s *Solver) applyExecutorState(st ExecutorState, bal *big.Int, now time.Time) { + s.pruneReservations(st.Nonce.Uint64(), now) // free bids the fresh read shows resolved (or aged out) + s.nonces.reconcile(st.Nonce.Uint64()) + if bal != nil { + s.metrics.balances(weiFloat(st.Deposit), weiFloat(bal)) + } + // Deposit-drain alert: the gas pool drains on every settlement (even reverts; gas is debited from the + // deposit post-settlement, §6.2). Below MIN_DEPOSIT settlement always reverts, so surface that floor + // breach loudly; per-bid predicted-gas headroom is checked in buildBid. + belowFloor := st.Deposit.Cmp(minDeposit) < 0 + s.metrics.depositBelowFloor(belowFloor) + if belowFloor { + s.log.Error(errors.New("executor deposit below MIN_DEPOSIT"), + "bidding will skip until refueled (scripts/oev/oev-balance.sh topup-deposit)", + "depositWei", st.Deposit, "minDepositWei", minDeposit) + } + s.log.V(1).Info("state", "nonce", st.Nonce, "depositWei", st.Deposit, "locked", st.Locked, "callbackWei", bal) +} + +// handleMessage dispatches an inbound WS frame by op. Unknown/garbled frames are logged and dropped +// (the auctioneer is lenient and silent on bad input — §6.7). +func (s *Solver) handleMessage(ctx context.Context, raw []byte) { + op, err := opName(raw) + if err != nil { + s.log.V(1).Error(err, "drop unparseable frame") + return + } + switch op { + case "auction": + if isFeedAuction(raw) { + s.log.V(1).Info("ignoring feed auction") + return + } + s.handleAuction(raw) + case "auction-result": + var r AuctionResult + if err := json.Unmarshal(raw, &r); err != nil { + s.log.V(1).Error(err, "drop malformed frame", "op", op) + } else { + won := strings.EqualFold(r.Data.Liquidator, s.cfg.Callback.Hex()) + if won { + s.metrics.won() + } else { + s.releaseReservationByAuction(r.ID) + } + s.log.Info("auction-result", "id", r.ID, "winner", r.Data.Liquidator, "bid", r.Data.Bid, "won", won) + } + case "liquidation-result": + var r LiquidationResult + if err := json.Unmarshal(raw, &r); err != nil { + s.log.V(1).Error(err, "drop malformed frame", "op", op) + } else { + // This is the breaker's failure feed. The frame is delivered on both the broadcast oev/liquidations + // and the callback-scoped oev/notify/ subscription, so a result may belong to another + // solver — gate on Data.Liquidator == our callback (same won-detection as auction-result) before + // recording a failure, so a revert storm trips the breaker but other solvers' reverts never do. + ours := strings.EqualFold(r.Data.Liquidator, s.cfg.Callback.Hex()) + pred, hasPred := s.reservationByAuction(r.ID) + s.log.Info("liquidation-result", "id", r.ID, "success", r.Data.Success, + "txHash", r.Data.TxHash, "error", r.Data.Error, "ours", ours, + "predictedGas", pred.gasUnits, "predictedRoute", pred.gasRoutes) + if ours { + if hasPred && r.Data.TxHash != "" { + go s.attributeSettlementGas(ctx, r.Data.TxHash, pred) + } + s.releaseReservationByAuction(r.ID) + if !r.Data.Success { + s.breaker.recordFailure(time.Now()) + s.metrics.failed() + } + } + } + case "blacklisted": + var b Blacklisted + _ = json.Unmarshal(raw, &b) + s.breaker.blacklist() // actually halt bidding, not just log + s.log.Error(errors.New("api key blacklisted"), "halting bidding", "msg", b.Data.Msg) + default: + s.log.V(1).Info("ignoring frame", "op", op) + } +} + +// bidDecision is the outcome of evaluating one auction: either a ready-to-send solve (skip == "") or +// a bounded skip reason (a metric label, never free-form/attacker-derived). gross is the bundle's Σ +// loan-token profit, carried for logging only. +type bidDecision struct { + solve SolveMessage + legs int + gross *big.Int + bidNative *big.Int + gasNative *big.Int + nonce uint64 // the bid's signed nonce, so handleAuction reserves headroom keyed on it + positions []positionKey // the bundle's (market,borrower) legs, reserved so they aren't re-bid in-flight + gas gasPrediction + skip string +} + +// handleAuction is the hot path: unmarshal, run the (testable, I/O-free) buildBid, emit metrics, and +// either send the solve or log the skip. The ~400ms auction budget is observed via the hotPath latency +// histogram. +func (s *Solver) handleAuction(raw []byte) { + start := time.Now() + var a AuctionMessage + if err := json.Unmarshal(raw, &a); err != nil { + s.log.V(1).Error(err, "drop malformed auction") + return + } + s.metrics.auction() + // Drop a duplicate delivery of the same auction (a reconnect re-subscribe can replay frames): bidding + // twice would burn a second nonce and reserve a second headroom for one auction. An empty-id frame still + // dedups — on a content hash (dedupKey) — so a replayed id-less frame can't slip past and double-bid. + if s.seen.seen(a.dedupKey()) { + s.metrics.skip("duplicate") + s.log.V(1).Info("duplicate auction; already processed", "auction", a.ID) + return + } + d := s.buildBid(a, time.Now) + s.metrics.latency(time.Since(start)) + + if d.skip != "" { + s.metrics.skip(d.skip) + s.log.V(1).Info("no bid", "auction", a.ID, "reason", d.skip) + return + } + if s.dryRun { + s.metrics.bid() + s.log.Info("DRY-RUN would bid", "auction", a.ID, "legs", d.legs, "nonce", d.solve.Data.Nonce, + "bidEth", d.solve.Data.Bid, "grossProfit", d.gross, "predictedGas", d.gas.Units, + "predictedRoute", gasRoutesString(d.gas.Routes)) + return + } + // Don't send a bid that overran the auction's own deadline: the auctioneer rejects late solves, and a + // sent-but-rejected bid would still reserve funding until the next refresh. Measure against the auction's + // TRUE deadline — elapsed since the auctioneer EMITTED the frame (a.Timestamp) — so a late-DELIVERED frame + // (WS transit) is dropped, not just one we were slow to process. Matters most with a remote signer whose + // latency lands inside buildBid's SignBid. + if a.TimeoutMs > 0 && tooLate(a.Timestamp, a.TimeoutMs, start, time.Now()) { + s.metrics.skip("too_late") + s.log.Info("bid not sent: auction deadline (since emit) exceeded", + "auction", a.ID, "timeoutMs", a.TimeoutMs, "sinceEmitMs", sinceEmitMs(a.Timestamp, time.Now()), + "localElapsedMs", time.Since(start).Milliseconds()) + return + } + if !s.ws.Send(marshal(d.solve)) { + // The frame never left the process — don't count it as a bid or reserve its funding. + s.metrics.skip("send_dropped") + s.log.Info("bid NOT sent (ws buffer full)", "auction", a.ID, "nonce", d.solve.Data.Nonce) + return + } + s.reserve(d.bidNative, d.gasNative, d.nonce, time.Now(), d.positions, a.ID, d.gas) + s.metrics.bid() + s.log.Info("bid sent", "auction", a.ID, "legs", d.legs, "nonce", d.solve.Data.Nonce, + "bidEth", d.solve.Data.Bid, "grossProfit", d.gross, "predictedGas", d.gas.Units, + "predictedRoute", gasRoutesString(d.gas.Routes)) +} + +func (s *Solver) attributeSettlementGas(ctx context.Context, txHash string, pred reservedBid) { + if !common.IsHexHash(txHash) { + s.log.Info("settlement gas attribution skipped: bad tx hash", "txHash", txHash) + return + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + receipt, err := s.deps.Chain.TransactionReceipt(ctx, common.HexToHash(txHash)) + if err != nil { + s.log.Error(err, "settlement gas attribution failed", "txHash", txHash) + return + } + s.metrics.settlementGas(pred.gasUnits, receipt.GasUsed) + s.log.Info("settlement gas", "auction", pred.auctionID, "txHash", txHash, + "predictedGas", pred.gasUnits, "actualGas", receipt.GasUsed, "predictedRoute", pred.gasRoutes) + logCallbackEvents(s.log, s.cfg.Callback, receipt) +} + +// buildBid evaluates an auction end-to-end: pick candidates from the configured source, size +// liquidatable legs with the shared Morpho math, select the best after-cost bundle, run the O(1) pre-bid checks +// against cached state, and sign the EXECUTOR_V6 bid. It performs no I/O (reads only the in-memory +// snapshot/state caches and the local signer), so it is deterministic and unit-testable; nowFn is +// injected for breaker/accrual timing. A non-empty skip reason means no bid was built. +func (s *Solver) buildBid(a AuctionMessage, nowFn func() time.Time) bidDecision { + now := nowFn() + if tripped, _ := s.breaker.tripped(now); tripped { + return bidDecision{skip: "breaker"} + } + // Snapshot in-flight bids ONCE: the committed (market,borrower) set filters the scored legs below, and + // the reserved native debits the callback funding gate further down — all under a single resMu acquisition. + inFlight := s.inFlightSnapshot() + // Size legs off the monitor's snapshot. A stale cache contributes nothing (fail closed): one bid covers + // the whole selected bundle. + var scored []scoredLeg + staleSkip := "" + if ok, skip := s.fresh(a); !ok { + staleSkip = skip + } else { + scored = s.scoredLegs(a, now) + } + scored, hadLegs := dropInFlightLegs(scored, inFlight.positions) + if len(scored) == 0 { + switch { + case staleSkip != "": + return bidDecision{skip: staleSkip} // the snapshot was stale (fail closed) + case hadLegs: + return bidDecision{skip: "in_flight"} // all candidates already committed by unresolved bids + default: + return bidDecision{skip: skipNoLegs} + } + } + // Pre-bid checks (all O(1), from cached state). + st, ok := s.state.load() + if !ok { + return bidDecision{skip: "state_unknown"} + } + rate := s.rate(st) + if rate == nil || rate.Sign() <= 0 { + s.log.Info("bid skipped: loan/ETH rate unavailable", + "auction", a.ID, "scoredLegs", len(scored), "feedCount", len(a.Payload.Prices)) + return bidDecision{skip: skipGasUnprofitable} + } + gasPrice := new(big.Int).Set(s.cfg.MaxTxGasPrice) + feedCount := len(a.Payload.Prices) + b, skip := s.selectNetBundle(scored, rate, st.Gas, gasPrice, st.GasLimit, feedCount) + if skip != "" { + if skip == skipGasUnprofitable && len(b.legs) > 0 { + s.logBundleEconomics(a.ID, "bid skipped: bundle is not profitable after gas and bid", + b, rate, st.Gas, gasPrice, st.GasLimit, feedCount, len(scored)) + } + return bidDecision{skip: skip, gross: b.grossLoan} + } + priced := s.priceBundle(b, rate, st.Gas, gasPrice, feedCount) + + if st.Exec.Locked { + return bidDecision{skip: "signer_locked"} + } + if fundingSkip := s.fundingSkip(a, st, priced, inFlight, gasPrice); fundingSkip != "" { + return bidDecision{skip: fundingSkip} + } + // Encode operationData only after the cheap gates pass — it's the bundle's ABI pack, needed solely as + // SignBid's input, so defer it past state.load / signer_locked / deposit_low / callback_balance. + auth := operationAuth{AuctionKey: auctionKeyHash(a), BidAmount: priced.bidNative, MinBundleProfit: priced.minBundleProfitLoan} + authDigest, err := CallbackAuthDigest(s.chainID, s.cfg.Callback, s.cfg.Executor, auth, priced.callbackLegs) + if err != nil { + s.log.Error(err, "encode callback auth digest failed", "auction", a.ID) + return bidDecision{skip: "encode_error"} + } + authSig, err := s.deps.Signer.SignHash(authDigest) + if err != nil { + s.log.Error(err, "sign callback auth failed", "auction", a.ID) + return bidDecision{skip: "sign_error"} + } + opData, err := EncodeOperationData(auth, priced.callbackLegs, authSig) + if err != nil { + s.log.Error(err, "encode operationData failed", "auction", a.ID) + return bidDecision{skip: "encode_error"} + } + nonce := s.nonces.next(st.Exec.Nonce.Uint64()) + sig, err := SignBid(s.deps.Signer, s.chainID, s.cfg.Callback, opData, priced.bidNative, big.NewInt(int64(nonce)), gasPrice) + if err != nil { + s.log.Error(err, "sign bid failed", "auction", a.ID) + return bidDecision{skip: "sign_error"} + } + + positions := make([]positionKey, len(b.legs)) + for i, leg := range b.legs { + positions[i] = positionKey{market: leg.MarketId, borrower: leg.Borrower} + } + return bidDecision{ + legs: len(b.legs), + gross: b.grossLoan, + bidNative: priced.bidNative, + gasNative: priced.gasNative, + nonce: nonce, + positions: positions, + gas: priced.gas, + solve: SolveMessage{ + Op: "solve", ID: a.ID, + Data: SolveData{ + Bid: weiToEthString(priced.bidNative), + Nonce: new(big.Int).SetUint64(nonce).String(), + OperationCallback: s.cfg.Callback.Hex(), + OperationData: hexutil.Encode(opData), + LiquidationSig: hexutil.Encode(sig), + MaxTxGasPrice: gasPrice.String(), + Borrowers: b.borrowers, + }, + }, + } +} + +func (s *Solver) fundingSkip(a AuctionMessage, st cachedState, priced pricedBundle, inFlight inFlightState, gasPrice *big.Int) string { + requiredDeposit := new(big.Int).Add(minDeposit, priced.gasNative) + availableDeposit := new(big.Int).Sub(orZero(st.Exec.Deposit), inFlight.gasNative) + if availableDeposit.Cmp(requiredDeposit) < 0 { + s.log.Info("bid skipped: executor deposit cannot cover predicted gas", + "auction", a.ID, "depositWei", st.Exec.Deposit, "reservedGasWei", inFlight.gasNative, + "availableWei", availableDeposit, "requiredWei", requiredDeposit, + "minDepositWei", minDeposit, "predictedGas", priced.gas.Units, "gasPriceWei", gasPrice) + return skipDepositLow + } + + availableCallback := new(big.Int).Sub(orZero(st.CallbackNative), inFlight.bidNative) + if availableCallback.Cmp(priced.bidNative) < 0 { + s.log.Info("bid skipped: callback balance cannot cover bid", + "auction", a.ID, "callbackWei", st.CallbackNative, "reservedBidWei", inFlight.bidNative, + "availableWei", availableCallback, "requiredWei", priced.bidNative) + return skipCallbackBalance + } + return "" +} + +func dropInFlightLegs(scored []scoredLeg, inFlight map[positionKey]bool) ([]scoredLeg, bool) { + hadLegs := len(scored) > 0 + if len(inFlight) == 0 { + return scored, hadLegs + } + kept := scored[:0] + for _, sl := range scored { + if !inFlight[positionKey{sl.leg.MarketId, sl.leg.Borrower}] { + kept = append(kept, sl) + } + } + return kept, hadLegs +} + +func (s *Solver) logBundleEconomics(auctionID, msg string, b chosenBundle, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int, gasLimit uint64, feedCount, scoredLegs int) { + gas := gasPredictionForBundleFeeds(b, gasState, feedCount) + grossNative := loanToNative(b.grossLoan, rate) + gasNative := gasCostNative(gas.Units, gasPrice) + netNative := s.bundleNetNativeForFeeds(b, rate, gasState, gasPrice, feedCount) + bidNative := s.bundleBidNative(b, rate) + s.log.Info(msg, + "auction", auctionID, + "scoredLegs", scoredLegs, + "selectedLegs", len(b.legs), + "feedCount", feedCount, + "grossLoan", b.grossLoan, + "grossNative", grossNative, + "gasUnits", gas.Units, + "gasNative", gasNative, + "gasPriceWei", gasPrice, + "bidNative", bidNative, + "minBundleProfitNative", s.minBundleProfitNative(bidNative), + "netNative", netNative, + "gasLimit", gasLimit, + "usableGasLimit", usableBundleGasLimit(gasLimit), + "routes", gasRoutesString(gas.Routes)) +} + +// tooLate reports whether the auction's deadline has already passed: the window is timeoutMs measured from +// the auctioneer EMIT time (emitMs, absolute epoch-ms — the same field clampTsAt trusts). Using emit (not +// frame-receipt) charges WS transit against the budget, so a stale frame doesn't pass the gate and send a +// doomed bid that needlessly reserves headroom. Clock-skew guard, exactly like clampTsAt: if emitMs is 0 or +// in the FUTURE (emit > now — a bogus/forward timestamp), don't trust it — fall back to the local +// frame-receipt measure (now − start). caller gates this on timeoutMs > 0. +func tooLate(emitMs int64, timeoutMs int, start, now time.Time) bool { + window := time.Duration(timeoutMs) * time.Millisecond + if emitMs <= 0 || emitMs > now.UnixMilli() { // no/forward emit timestamp → trust the local clock + return now.Sub(start) > window + } + return now.UnixMilli()-emitMs > int64(timeoutMs) +} + +// sinceEmitMs is the elapsed ms since the auctioneer emitted the frame (≤0 when emit is unset/forward), +// for the too_late log line. +func sinceEmitMs(emitMs int64, now time.Time) int64 { + if emitMs <= 0 { + return 0 + } + return now.UnixMilli() - emitMs +} + +// clampTsAt derives the accrual timestamp from the auction's (attacker-influenceable) timestamp, +// clamped to a sane window around the given clock so a bogus value can't skew interest accrual. A +// future timestamp is never trusted (accruing past `now` over-states debt and could flag a healthy +// position) — it clamps to `now`, which under-accrues vs the later settlement block (fail closed). +func clampTsAt(auctionMs int64, now time.Time) uint64 { + nowSec := now.Unix() + if auctionMs <= 0 { + return uint64(nowSec) + } + ts := auctionMs / 1000 + const skew = 600 // tolerate up to 10 min of staleness in the past + if ts < nowSec-skew || ts > nowSec { + return uint64(nowSec) + } + return uint64(ts) +} + +// weiToEthString formats wei as a decimal ether string exactly (the solve `bid` field, which must +// equal formatEther(bidWei) — §6.1): integer/fraction split, 18-digit fraction, trailing zeros +// trimmed. +func weiToEthString(wei *big.Int) string { + q, r := new(big.Int).DivMod(wei, morpho.Wad, new(big.Int)) // wad = 1e18 + if r.Sign() == 0 { + return q.String() + } + frac := r.String() + for len(frac) < 18 { + frac = "0" + frac + } + frac = strings.TrimRight(frac, "0") + return q.String() + "." + frac +} + +// weiFloat converts wei to a float64 for gauge reporting only (lossy at >2^53; fine for dashboards). +func weiFloat(n *big.Int) float64 { + f, _ := new(big.Float).SetInt(n).Float64() + return f +} + +// cachedState is the atomically-swapped snapshot of the on-chain state needed for pre-bid checks: +// the signer's Executor accounting plus the callback contract's native balance (must cover the bid +// or payBid underpays). Written by the ops loop, read lock-free on the hot path. +type cachedState struct { + Exec ExecutorState + CallbackNative *big.Int + Rate *big.Int + Gas *gasPredictorState + GasLimit uint64 +} + +type stateCache struct { + p atomic.Pointer[cachedState] +} + +func (s *stateCache) store(v cachedState) { s.p.Store(&v) } + +func (s *stateCache) load() (cachedState, bool) { + v := s.p.Load() + if v == nil { + return cachedState{}, false + } + return *v, true +} diff --git a/internal/solvers/redstoneoev/testflags.go b/internal/solvers/redstoneoev/testflags.go new file mode 100644 index 00000000..36e279bd --- /dev/null +++ b/internal/solvers/redstoneoev/testflags.go @@ -0,0 +1,43 @@ +package redstoneoev + +// testflags.go reads dev/test knobs from env vars at point of use. Production leaves them unset. +// Malformed values fail closed (error) so a typo can't silently widen scope. + +import ( + "os" + "strings" + + "github.com/go-errors/errors" +) + +const ( + envOnchainPrice = "OEV_ONCHAIN_PRICE_FOR_TEST" // "true"/"1" → dev-testbed on-chain price basis + envTestMonitor = "OEV_TEST_MONITOR" // "true"/"1" → use Sepolia harness on-chain Morpho monitor + envDryRun = "OEV_DRY_RUN" // "true"/"1" → observe mode: sign + log would-bids, never send +) + +// onchainPriceForTestEnv reports whether OEV_ONCHAIN_PRICE_FOR_TEST selects the dev-testbed on-chain +// price basis ("true"/"1", case-insensitive); unset/false → false; a malformed value → error. +func onchainPriceForTestEnv() (bool, error) { return envBool(envOnchainPrice) } + +// testMonitorEnv reports whether OEV_TEST_MONITOR selects the Sepolia harness monitor that reads Morpho +// market/position state on-chain for configured test seeds. +func testMonitorEnv() (bool, error) { return envBool(envTestMonitor) } + +// dryRunEnv reports whether OEV_DRY_RUN puts the bot in observe mode — sign + log each would-bid but never +// send it ("true"/"1", case-insensitive); unset/false → false; a malformed value → error. +func dryRunEnv() (bool, error) { return envBool(envDryRun) } + +// envBool reads a boolean env flag, failing closed: unset/""/"false"/"0" → false; "true"/"1" → true +// (case-insensitive, trimmed); any other SET value → error — so a typo (e.g. OEV_DRY_RUN=ture) can't +// silently flip the bot into live bidding instead of the intended observe mode. +func envBool(key string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "", "false", "0": + return false, nil + case "true", "1": + return true, nil + default: + return false, errors.Errorf("%s: invalid bool %q (want true/1 or false/0)", key, os.Getenv(key)) + } +} diff --git a/internal/solvers/redstoneoev/testmonitor.go b/internal/solvers/redstoneoev/testmonitor.go new file mode 100644 index 00000000..bdbc2175 --- /dev/null +++ b/internal/solvers/redstoneoev/testmonitor.go @@ -0,0 +1,307 @@ +package redstoneoev + +import ( + "context" + "math/big" + "os" + "slices" + "strings" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +const ( + envTestMarkets = "OEV_TEST_MARKETS" + envTestPositions = "OEV_TEST_POSITIONS" +) + +// testMonitor is the Sepolia harness source. It enumerates nothing: markets/borrowers are supplied by the +// testbed manifest env, then market state and positions are read from the callback's Morpho contract. +type testMonitor struct { + reader *reader + log logr.Logger + callback common.Address + adapter common.Address + markets []common.Hash + positions []common.Address + monitorPoll time.Duration + + snap atomic.Pointer[snapshot] +} + +func newTestMonitor(r *reader, log logr.Logger, cfg *Config) (*testMonitor, error) { + markets, err := parseHashListEnv(envTestMarkets) + if err != nil { + return nil, err + } + if len(markets) == 0 { + return nil, errors.Errorf("%s: set at least one market id for %s", envTestMonitor, envTestMarkets) + } + positions, err := parseAddressListEnv(envTestPositions) + if err != nil { + return nil, err + } + if len(positions) == 0 { + return nil, errors.Errorf("%s: set at least one borrower for %s", envTestMonitor, envTestPositions) + } + m := &testMonitor{ + reader: r, + log: log.WithName("testMonitor"), + callback: cfg.Callback, + adapter: cfg.Adapter, + markets: markets, + positions: positions, + monitorPoll: cfg.MonitorPoll, + } + m.snap.Store(&snapshot{ + markets: map[common.Hash]MarketInfo{}, + prices: map[common.Hash]*big.Int{}, + quotes: map[common.Hash]AdapterQuote{}, + positions: map[common.Hash]map[common.Address]morpho.PositionState{}, + }) + return m, nil +} + +func (m *testMonitor) run(ctx context.Context) { + tick := time.NewTicker(m.monitorPoll) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + m.refresh(ctx) + } + } +} + +func (m *testMonitor) name() string { return "test" } + +func (m *testMonitor) refresh(ctx context.Context) { + header, err := m.reader.chain.HeaderByNumber(ctx, nil) + if err != nil || header == nil || header.Number == nil || !header.Number.IsUint64() { + m.log.Error(err, "test monitor header read failed; keeping cache") + return + } + morphoAddr, err := m.reader.callAddress(ctx, m.callback, callbackB.PackMORPHO(), callbackB.UnpackMORPHO) + if err != nil || morphoAddr == (common.Address{}) { + m.log.Error(err, "test monitor MORPHO read failed; keeping cache") + return + } + adapter, err := m.reader.readAdapterSnapshot(ctx, m.callback, m.adapter) + if err != nil { + m.log.Error(err, "test monitor adapter state unreadable; keeping cache") + return + } + params, err := m.reader.ResolveParams(ctx, morphoAddr, m.markets) + if err != nil { + m.log.Error(err, "test monitor market params read failed; keeping cache") + return + } + served := verifyAdapterPair(params, adapter.loan, adapter.redeemable) + want := make(map[common.Hash]abiMarketParams, len(served)) + serve := make(map[common.Hash]bool, len(served)) + for _, id := range served { + want[id] = params[id] + serve[id] = adapter.filler + } + if len(want) == 0 { + m.log.V(1).Info("test monitor found no adapter-served markets") + return + } + markets, prices, err := m.readMarkets(ctx, morphoAddr, want) + if err != nil { + m.log.Error(err, "test monitor market state read failed; keeping cache") + return + } + quotes, err := m.reader.ReadAdapterQuotes(ctx, want, m.adapter, serve) + if err != nil { + m.log.Error(err, "test monitor adapter quote read failed; keeping cache") + return + } + positions, err := m.readPositions(ctx, morphoAddr, markets) + if err != nil { + m.log.Error(err, "test monitor positions read failed; keeping cache") + return + } + end, err := m.reader.chain.HeaderByNumber(ctx, nil) + if err != nil || end == nil || end.Number == nil || !end.Number.IsUint64() { + m.log.Error(err, "test monitor end-header read failed; keeping cache") + return + } + if end.Number.Uint64() != header.Number.Uint64() { + m.log.V(1).Info("test monitor refresh crossed block boundary; keeping cache", + "startBlock", header.Number.Uint64(), "endBlock", end.Number.Uint64()) + return + } + m.snap.Store(&snapshot{ + markets: markets, prices: prices, quotes: compactQuotes(quotes), positions: positions, + block: header.Number.Uint64(), blockTime: header.Time, + }) +} + +func (m *testMonitor) readMarkets(ctx context.Context, morphoAddr common.Address, params map[common.Hash]abiMarketParams) (map[common.Hash]MarketInfo, map[common.Hash]*big.Int, error) { + ids := sortedMarketIDs(params) + calls := make([]chain.Call, 0, len(ids)*2) + for _, id := range ids { + p := params[id] + calls = append(calls, + chain.Call{Target: morphoAddr, AllowFailure: true, Data: morphoB.PackMarket(id)}, + chain.Call{Target: p.Oracle, AllowFailure: true, Data: oracleB.PackPrice()}, + ) + } + res, err := m.reader.chain.Multicall(ctx, calls) + if err != nil { + return nil, nil, err + } + if len(res) != len(calls) { + return nil, nil, errors.Errorf("testMonitor markets: got %d results, want %d", len(res), len(calls)) + } + markets := make(map[common.Hash]MarketInfo, len(ids)) + prices := make(map[common.Hash]*big.Int, len(ids)) + for i, id := range ids { + marketRes := res[i*2] + priceRes := res[i*2+1] + if !marketRes.Success || !priceRes.Success { + continue + } + state, ok := decodeTestMarketState(marketRes.ReturnData, params[id]) + if !ok { + continue + } + price, err := oracleB.UnpackPrice(priceRes.ReturnData) + if err != nil || price == nil || price.Sign() <= 0 { + continue + } + markets[id] = MarketInfo{Params: params[id], State: state} + prices[id] = price + } + return markets, prices, nil +} + +func (m *testMonitor) readPositions(ctx context.Context, morphoAddr common.Address, markets map[common.Hash]MarketInfo) (map[common.Hash]map[common.Address]morpho.PositionState, error) { + ids := sortedMarketIDsFromInfo(markets) + calls := make([]chain.Call, 0, len(ids)*len(m.positions)) + type slot struct { + id common.Hash + borrower common.Address + } + slots := make([]slot, 0, cap(calls)) + for _, id := range ids { + for _, borrower := range m.positions { + slots = append(slots, slot{id: id, borrower: borrower}) + calls = append(calls, chain.Call{Target: morphoAddr, AllowFailure: true, Data: morphoB.PackPosition(id, borrower)}) + } + } + res, err := m.reader.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("testMonitor positions: got %d results, want %d", len(res), len(calls)) + } + out := make(map[common.Hash]map[common.Address]morpho.PositionState, len(ids)) + for i, s := range slots { + if !res[i].Success { + continue + } + p, err := morphoB.UnpackPosition(res[i].ReturnData) + if err != nil || p.BorrowShares == nil || p.Collateral == nil { + continue + } + if out[s.id] == nil { + out[s.id] = make(map[common.Address]morpho.PositionState) + } + out[s.id][s.borrower] = morpho.PositionState{BorrowShares: p.BorrowShares, Collateral: p.Collateral} + } + return out, nil +} + +func decodeTestMarketState(data []byte, params abiMarketParams) (morpho.MarketState, bool) { + out, err := morphoB.UnpackMarket(data) + if err != nil || out.TotalSupplyAssets == nil || out.TotalSupplyShares == nil || + out.TotalBorrowAssets == nil || out.TotalBorrowShares == nil || out.LastUpdate == nil || + out.Fee == nil || params.Lltv == nil || !out.LastUpdate.IsUint64() { + return morpho.MarketState{}, false + } + return morpho.MarketState{ + TotalSupplyAssets: out.TotalSupplyAssets, + TotalSupplyShares: out.TotalSupplyShares, + TotalBorrowAssets: out.TotalBorrowAssets, + TotalBorrowShares: out.TotalBorrowShares, + LastUpdate: out.LastUpdate.Uint64(), + Fee: out.Fee, + Lltv: params.Lltv, + BorrowRatePerSec: big.NewInt(0), + }, true +} + +func sortedMarketIDs(params map[common.Hash]abiMarketParams) []common.Hash { + ids := make([]common.Hash, 0, len(params)) + for id := range params { + ids = append(ids, id) + } + slices.SortFunc(ids, common.Hash.Cmp) + return ids +} + +func sortedMarketIDsFromInfo(markets map[common.Hash]MarketInfo) []common.Hash { + ids := make([]common.Hash, 0, len(markets)) + for id := range markets { + ids = append(ids, id) + } + slices.SortFunc(ids, common.Hash.Cmp) + return ids +} + +func (m *testMonitor) candidates(auction AuctionMessage, nowTs uint64) []evalItem { + return candidatesFromCachedPrices(m.snapshot(), nowTs) +} + +func (m *testMonitor) snapshot() *snapshot { + return m.snap.Load() +} + +func parseHashListEnv(key string) ([]common.Hash, error) { + parts := splitEnvList(os.Getenv(key)) + out := make([]common.Hash, 0, len(parts)) + for _, p := range parts { + if !common.IsHexHash(p) { + return nil, errors.Errorf("%s: invalid hash %q", key, p) + } + out = append(out, common.HexToHash(p)) + } + return out, nil +} + +func parseAddressListEnv(key string) ([]common.Address, error) { + parts := splitEnvList(os.Getenv(key)) + out := make([]common.Address, 0, len(parts)) + for _, p := range parts { + if !common.IsHexAddress(p) { + return nil, errors.Errorf("%s: invalid address %q", key, p) + } + out = append(out, common.HexToAddress(p)) + } + return out, nil +} + +func splitEnvList(v string) []string { + fields := strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == '\n' || r == '\t' || r == ' ' + }) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} diff --git a/internal/solvers/redstoneoev/wsclient.go b/internal/solvers/redstoneoev/wsclient.go new file mode 100644 index 00000000..8629bcbe --- /dev/null +++ b/internal/solvers/redstoneoev/wsclient.go @@ -0,0 +1,246 @@ +package redstoneoev + +import ( + "context" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "github.com/gorilla/websocket" +) + +// wsConfig tunes the resilient WS client. Timings default to the RedStone example client's values +// (docs/OEV-PLAN.md §6.1): server pings ~120s, connections forced-closed ~8h (rotate at ~7h). +type wsConfig struct { + URL string + APIKey string + Topics []string + + HandshakeTimeout time.Duration + PingInterval time.Duration + MsgTimeout time.Duration // reconnect if no inbound frame/pong within this + RotateAfter time.Duration // proactively reconnect before the server's ~8h cutoff + BackoffInitial time.Duration + BackoffMax time.Duration +} + +func (c *wsConfig) withDefaults() { + setDur(&c.HandshakeTimeout, 10*time.Second) + setDur(&c.PingInterval, 20*time.Second) + setDur(&c.MsgTimeout, 30*time.Second) + setDur(&c.RotateAfter, 7*time.Hour) + setDur(&c.BackoffInitial, 500*time.Millisecond) + setDur(&c.BackoffMax, 30*time.Second) +} + +// wsClient is a reconnecting WebSocket client: it (re)connects with the x-api-key header, re-sends +// the topic subscriptions after every connect, pings to keep the link alive, rotates before the +// server's cutoff, and delivers inbound frames to onMessage. Outbound solve frames go through Send, +// which is safe for the hot path (non-blocking; drops + returns false if the buffer is full) and +// discards stale buffered solves on every (re)connect. +type wsClient struct { + cfg wsConfig + log logr.Logger + onMsg func(context.Context, []byte) + dialer *websocket.Dialer + header http.Header + send chan []byte +} + +func newWSClient(cfg wsConfig, log logr.Logger, onMsg func(context.Context, []byte)) *wsClient { + cfg.withDefaults() + h := http.Header{} + h.Set("x-api-key", cfg.APIKey) + return &wsClient{ + cfg: cfg, + log: log.WithName("ws"), + onMsg: onMsg, + dialer: &websocket.Dialer{HandshakeTimeout: cfg.HandshakeTimeout}, + header: h, + send: make(chan []byte, 8), + } +} + +// Send enqueues an outbound frame (e.g. a solve) and reports whether it was accepted. Non-blocking: if +// the buffer is full the frame is dropped (returns false) so the hot path never blocks. A solve only +// lives ~400ms (one auction), so stale frames are dropped on reconnect (flushSendQueue) rather than +// written late to a closed auction — callers must treat false as "not sent" (don't count it as a bid). +func (w *wsClient) Send(frame []byte) bool { + select { + case w.send <- frame: + return true + default: + w.log.Info("ws send dropped (buffer full)") + return false + } +} + +// Run connects and serves until ctx is cancelled, reconnecting with jittered exponential backoff. +func (w *wsClient) Run(ctx context.Context) error { + backoff := w.cfg.BackoffInitial + for { + start := time.Now() + err := w.serveOnce(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + w.log.Error(err, "ws connection ended; reconnecting") + } + // Reset backoff if the last connection was healthy for a while. + if time.Since(start) > w.cfg.BackoffMax { + backoff = w.cfg.BackoffInitial + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff + jitter()): + } + if backoff *= 2; backoff > w.cfg.BackoffMax { + backoff = w.cfg.BackoffMax + } + } +} + +// serveOnce dials, subscribes, and runs the read/write pumps until the connection drops, rotates, or +// ctx is cancelled. +func (w *wsClient) serveOnce(ctx context.Context) error { + conn, resp, err := w.dialer.DialContext(ctx, w.cfg.URL, w.header) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() // handshake response body; not used + } + if err != nil { + return errors.Errorf("dial %s: %w", w.cfg.URL, err) + } + w.log.Info("connected", "url", w.cfg.URL) + + // Drop any solves buffered during the downtime: a solve targets one auction (~400ms life), so + // anything still queued after a reconnect is stale. Start each connection with a clean send queue. + flushSendQueue(w.send) + + connCtx, cancel := context.WithCancel(ctx) + + // (Re)subscribe to all topics. + for _, topic := range w.cfg.Topics { + if werr := conn.WriteMessage(websocket.TextMessage, marshal(SubscribeMessage{Op: "subscribe", Topic: topic})); werr != nil { + cancel() + _ = conn.Close() + return errors.Errorf("subscribe %s: %w", topic, werr) + } + } + w.log.Info("subscribed", "topics", w.cfg.Topics) + + errCh := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); w.writePump(connCtx, conn, errCh) }() + go func() { defer wg.Done(); w.readPump(connCtx, conn, errCh) }() + + var retErr error + select { + case <-ctx.Done(): + retErr = ctx.Err() + case e := <-errCh: + retErr = e + } + // Tear the connection down and JOIN both pumps before returning, so no pump goroutine — and no + // second reader competing for w.send — outlives this connection into the next reconnect. + cancel() + _ = conn.Close() + wg.Wait() + return retErr +} + +// readPump reads frames, extends the read deadline on each, dispatches to onMsg, and answers server +// pings (gorilla auto-replies to pings via the default handler; we extend the deadline too). +func (w *wsClient) readPump(ctx context.Context, conn *websocket.Conn, errCh chan<- error) { + _ = conn.SetReadDeadline(time.Now().Add(w.cfg.MsgTimeout)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(w.cfg.MsgTimeout)) + }) + conn.SetPingHandler(func(appData string) error { + _ = conn.SetReadDeadline(time.Now().Add(w.cfg.MsgTimeout)) + // Reply pong via the write pump is simplest, but gorilla allows a direct control write. + _ = conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(time.Second)) + return nil + }) + for { + _, data, err := conn.ReadMessage() + if err != nil { + select { + case errCh <- errors.Errorf("read: %w", err): + default: + } + return + } + _ = conn.SetReadDeadline(time.Now().Add(w.cfg.MsgTimeout)) + if ctx.Err() != nil { + return + } + w.onMsg(ctx, data) + } +} + +// writePump owns all writes (gorilla requires a single writer): outbound frames, periodic pings, and +// a rotation timer that forces a clean reconnect before the server's cutoff. +func (w *wsClient) writePump(ctx context.Context, conn *websocket.Conn, errCh chan<- error) { + ping := time.NewTicker(w.cfg.PingInterval) + defer ping.Stop() + rotate := time.NewTimer(w.cfg.RotateAfter + jitter()) + defer rotate.Stop() + for { + select { + case <-ctx.Done(): + return + case frame := <-w.send: + _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err := conn.WriteMessage(websocket.TextMessage, frame); err != nil { + w.nonblockErr(errCh, errors.Errorf("write: %w", err)) + return + } + case <-ping.C: + _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil { + w.nonblockErr(errCh, errors.Errorf("ping: %w", err)) + return + } + case <-rotate.C: + w.log.Info("rotating connection before server cutoff") + w.nonblockErr(errCh, errors.New("rotate")) + return + } + } +} + +func (w *wsClient) nonblockErr(errCh chan<- error, err error) { + select { + case errCh <- err: + default: + } +} + +// flushSendQueue empties the outbound buffer without blocking (used on (re)connect to discard stale solves). +func flushSendQueue(ch chan []byte) { + for { + select { + case <-ch: + default: + return + } + } +} + +func setDur(d *time.Duration, def time.Duration) { + if *d <= 0 { + *d = def + } +} + +// jitter returns 1–5s of randomness to desynchronize reconnects (matches the example client). The +// reconnect path is not security-sensitive, so math/rand is fine. +func jitter() time.Duration { + return time.Duration(1000+rand.Intn(4000)) * time.Millisecond //nolint:gosec // non-crypto jitter +} diff --git a/internal/solvers/redstoneoev/wsmessages.go b/internal/solvers/redstoneoev/wsmessages.go new file mode 100644 index 00000000..582bf7dd --- /dev/null +++ b/internal/solvers/redstoneoev/wsmessages.go @@ -0,0 +1,143 @@ +package redstoneoev + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/go-errors/errors" +) + +// Hand-written WS structs pinned to RedStone's zod schema and live auction frames; there is no upstream +// OpenAPI to generate from. +func opName(raw []byte) (string, error) { + var head struct { + Op string `json:"op"` + } + if err := json.Unmarshal(raw, &head); err != nil { + return "", errors.Errorf("ws: decode op: %w", err) + } + return head.Op, nil +} + +func isFeedAuction(raw []byte) bool { + var frame struct { + Payload map[string]json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(raw, &frame); err != nil || len(frame.Payload) == 0 { + return false + } + if _, ok := frame.Payload["positions"]; ok { + return false + } + if _, ok := frame.Payload["prices"]; ok { + return false + } + return true +} + +type AuctionMessage struct { + Op string `json:"op"` + ID string `json:"id"` + Timestamp int64 `json:"timestamp"` + TimeoutMs int `json:"timeoutMs"` + Payload AuctionPayload `json:"payload"` +} + +type AuctionPayload struct { + Prices map[string]string `json:"prices"` +} + +// dedupKey returns the key used to suppress a replayed delivery of this auction. The auctioneer's `id` is +// authoritative when present; when it's empty (some frames carry none) we'd otherwise NEVER record the +// frame as seen, so a replay would be processed twice → a second nonce + a double bid. So derive a synthetic +// key from the frame content — a hash over the auctioneer emit timestamp/timeout plus the sorted prices. +// Folding in the emit timestamp is essential: two genuinely-distinct id-less auctions at the SAME price +// (e.g. the same oracle re-auctioned) emit at different times, so without it the second would collide with +// the first and be wrongly dropped as a duplicate. A reconnect REPLAY of one frame carries the same +// timestamp, so it still dedups. Prefixed by source so a content hash can never collide with a real id. +func (a AuctionMessage) dedupKey() string { + if a.ID != "" { + return "id:" + a.ID + } + prices := make([]string, 0, len(a.Payload.Prices)) + for k, v := range a.Payload.Prices { + prices = append(prices, k+"="+v) + } + sort.Strings(prices) + h := sha256.New() + // Emit timestamp + timeout first: distinguishes two same-price auctions emitted at different times, + // while a replay of the same frame (same timestamp/timeout) still hashes identically. + fmt.Fprintf(h, "%d|%d\x00", a.Timestamp, a.TimeoutMs) + h.Write([]byte(strings.Join(prices, ","))) + return "hash:" + hex.EncodeToString(h.Sum(nil)) +} + +type AuctionResult struct { + Op string `json:"op"` + ID string `json:"id"` + Data AuctionResultData `json:"data"` +} + +type AuctionResultData struct { + Bid string `json:"bid"` + Liquidator string `json:"liquidator"` +} + +type LiquidationResult struct { + Op string `json:"op"` + ID string `json:"id"` + Data LiquidationResultData `json:"data"` +} + +type LiquidationResultData struct { + Success bool `json:"success"` + TxHash string `json:"txHash"` + Liquidator string `json:"liquidator"` + Error string `json:"error"` +} + +type Blacklisted struct { + Op string `json:"op"` + ID string `json:"id"` + Data BlacklistedData `json:"data"` +} + +type BlacklistedData struct { + Liquidator string `json:"liquidator"` + Msg string `json:"msg"` +} + +type SubscribeMessage struct { + Op string `json:"op"` + Topic string `json:"topic"` +} + +type SolveMessage struct { + Op string `json:"op"` + ID string `json:"id"` + Data SolveData `json:"data"` +} + +// SolveData carries the bid. `bid` is a decimal ether string of the signed wei bidAmount; `nonce` +// and `maxTxGasPrice` are decimal strings; `operationData`/`liquidationSig` are 0x-hex. +type SolveData struct { + Bid string `json:"bid"` + Nonce string `json:"nonce"` + OperationCallback string `json:"operationCallback"` + OperationData string `json:"operationData"` + LiquidationSig string `json:"liquidationSig"` + MaxTxGasPrice string `json:"maxTxGasPrice"` + Borrowers []string `json:"borrowers,omitempty"` +} + +func marshal(v any) []byte { + b, err := json.Marshal(v) + if err != nil { // unreachable: our outbound shapes are static and marshal-safe + panic("redstoneoev: marshal: " + err.Error()) + } + return b +} From 7489d44a42831e4dd18efe98be59909d11a73a5b Mon Sep 17 00:00:00 2001 From: alrxy Date: Fri, 26 Jun 2026 18:59:12 +0700 Subject: [PATCH 09/50] test: add RedStone OEV coverage and Sepolia tooling --- config/redstone-oev.sepolia.example.yaml | 52 + internal/solvers/redstoneoev/breaker_test.go | 50 + .../solvers/redstoneoev/chainreader_test.go | 263 +++ internal/solvers/redstoneoev/config_test.go | 299 ++++ internal/solvers/redstoneoev/eip191_test.go | 72 + .../solvers/redstoneoev/gaspredictor_test.go | 235 +++ .../redstoneoev/live_fork_payload_test.go | 129 ++ internal/solvers/redstoneoev/live_test.go | 113 ++ internal/solvers/redstoneoev/monitor_test.go | 217 +++ .../solvers/redstoneoev/morphoapi_test.go | 289 +++ .../redstoneoev/operationdata_decode_test.go | 147 ++ .../solvers/redstoneoev/operationdata_test.go | 137 ++ internal/solvers/redstoneoev/sizing_test.go | 301 ++++ internal/solvers/redstoneoev/solver_test.go | 1572 +++++++++++++++++ .../solvers/redstoneoev/testhelpers_test.go | 36 + .../solvers/redstoneoev/testsigner_test.go | 72 + .../solvers/redstoneoev/wsintegration_test.go | 76 + .../solvers/redstoneoev/wsmessages_test.go | 109 ++ scripts/oev/addresses.sepolia.json | 25 + scripts/oev/oev-balance.sh | 272 +++ scripts/oev/oev-fork-refuel.sh | 61 + scripts/oev/oev-testrun.sh | 113 ++ 22 files changed, 4640 insertions(+) create mode 100644 config/redstone-oev.sepolia.example.yaml create mode 100644 internal/solvers/redstoneoev/breaker_test.go create mode 100644 internal/solvers/redstoneoev/chainreader_test.go create mode 100644 internal/solvers/redstoneoev/config_test.go create mode 100644 internal/solvers/redstoneoev/eip191_test.go create mode 100644 internal/solvers/redstoneoev/gaspredictor_test.go create mode 100644 internal/solvers/redstoneoev/live_fork_payload_test.go create mode 100644 internal/solvers/redstoneoev/live_test.go create mode 100644 internal/solvers/redstoneoev/monitor_test.go create mode 100644 internal/solvers/redstoneoev/morphoapi_test.go create mode 100644 internal/solvers/redstoneoev/operationdata_decode_test.go create mode 100644 internal/solvers/redstoneoev/operationdata_test.go create mode 100644 internal/solvers/redstoneoev/sizing_test.go create mode 100644 internal/solvers/redstoneoev/solver_test.go create mode 100644 internal/solvers/redstoneoev/testhelpers_test.go create mode 100644 internal/solvers/redstoneoev/testsigner_test.go create mode 100644 internal/solvers/redstoneoev/wsintegration_test.go create mode 100644 internal/solvers/redstoneoev/wsmessages_test.go create mode 100644 scripts/oev/addresses.sepolia.json create mode 100755 scripts/oev/oev-balance.sh create mode 100755 scripts/oev/oev-fork-refuel.sh create mode 100755 scripts/oev/oev-testrun.sh diff --git a/config/redstone-oev.sepolia.example.yaml b/config/redstone-oev.sepolia.example.yaml new file mode 100644 index 00000000..4e3173ca --- /dev/null +++ b/config/redstone-oev.sepolia.example.yaml @@ -0,0 +1,52 @@ +# vault-solver — RedStone OEV solver, Sepolia profile. +# +# Secrets are referenced by env-var NAME and read at point of use; ${VAR} fields are expanded at load time. + +chain: + rpcUrl: ${ETH_RPC_URL_SEPOLIA} # expanded from env; do not commit a real URL + chainId: 11155111 + +signer: + keyEnv: OEV_SIGNER_PRIVATE_KEY # EXECUTOR_V6 signer + Executor deposit wallet + +observability: + addr: ":9090" + +solvers: + - name: redstone-oev + config: + ws: + url: wss://dev-rwa-sepolia.oev.a.redstone.finance + apiKeyEnv: OEV_REDSTONE_API_KEY + + executor: "0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD" # RedStone Atom Executor (proxy) + callback: "0x7EE46765Bd337931E9f2CF6333BeBf2b78D17fcf" # SymbioticOevSolver (sells each leg through the LiquidLane adapter; pays the bid) + adapter: "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b" # the LiquidLane adapter the callback pins (TLOAN vault / TCOL) + + # Public api.morpho.org does not index this custom Sepolia Morpho. The Sepolia harness uses + # OEV_TEST_MONITOR=true plus OEV_TEST_MARKETS/OEV_TEST_POSITIONS from scripts/oev/addresses.sepolia.json. + morphoApiUrl: "" + + # TLOAN is the Sepolia testbed's $1 loan token, so loanUsd uses Chainlink's Sepolia USDC/USD feed. + loanEthFeed: + ethUsd: "0x694AA1769357215DE4FAC081bf1f309aDC325306" # Chainlink Sepolia ETH / USD + loanUsd: "0xA2F78ab2355fe2f984D808B5CeE7FD0A93D5270E" # Chainlink Sepolia USDC / USD proxy for TLOAN + maxAgeMs: 86400000 + + bid: + bidEth: "0.0005" # minimum bid in ETH; final bid is max(this, gross profit share) + totalBundleProfitBps: 0 # optional bid share of gross bundle profit, in native terms + minBundleProfitBidBps: 1000 # extra bundle margin after gas + bid, as bps of the final bid + maxTxGasPriceWei: "20000000000" # 20 gwei signed tx.gasprice cap and profitability/deposit assumption + + sizing: + allowFullLiquidation: true # seize full collateral when profitable; set false to force fixed 90% partial mode + swapHaircutBps: 100 # extra safety margin on the adapter's discounted rate (slippage/staleness) + + breaker: + maxFailures: 3 # halt bidding after this many failed liquidations… + windowMs: 3600000 # …within 1h (plus an immediate halt on a `blacklisted` frame) + + intervals: + opsPollMs: 30000 # ops checks (balances, filler status) + monitorPollMs: 10000 # monitor snapshot poll (API in prod, testMonitor on Sepolia) diff --git a/internal/solvers/redstoneoev/breaker_test.go b/internal/solvers/redstoneoev/breaker_test.go new file mode 100644 index 00000000..136dc9d7 --- /dev/null +++ b/internal/solvers/redstoneoev/breaker_test.go @@ -0,0 +1,50 @@ +package redstoneoev + +import ( + "testing" + "time" +) + +func TestBreakerBlacklistHalts(t *testing.T) { + b := newBreaker(3, time.Hour) + now := time.Unix(1_000_000, 0) + if tripped, _ := b.tripped(now); tripped { + t.Fatal("fresh breaker must not be tripped") + } + b.blacklist() + tripped, why := b.tripped(now) + if !tripped || why != "api key blacklisted" { + t.Fatalf("blacklist must trip: %v %q", tripped, why) + } +} + +func TestBreakerFailureRateLimit(t *testing.T) { + b := newBreaker(3, time.Hour) + base := time.Unix(2_000_000, 0) + b.recordFailure(base) + b.recordFailure(base.Add(time.Minute)) + if tripped, _ := b.tripped(base.Add(2 * time.Minute)); tripped { + t.Fatal("2 failures < 3 must not trip") + } + b.recordFailure(base.Add(3 * time.Minute)) + tripped, why := b.tripped(base.Add(4 * time.Minute)) + if !tripped || why != "failed-liquidation rate-limit" { + t.Fatalf("3 failures must trip: %v %q", tripped, why) + } +} + +func TestBreakerWindowPrunes(t *testing.T) { + b := newBreaker(3, time.Hour) + base := time.Unix(3_000_000, 0) + b.recordFailure(base) + b.recordFailure(base.Add(time.Minute)) + b.recordFailure(base.Add(2 * time.Minute)) + // All three are within the window -> tripped. + if tripped, _ := b.tripped(base.Add(3 * time.Minute)); !tripped { + t.Fatal("3 in-window failures must trip") + } + // Two hours later they're all pruned -> not tripped. + if tripped, _ := b.tripped(base.Add(2 * time.Hour)); tripped { + t.Fatal("failures older than the window must be pruned") + } +} diff --git a/internal/solvers/redstoneoev/chainreader_test.go b/internal/solvers/redstoneoev/chainreader_test.go new file mode 100644 index 00000000..12cf5acc --- /dev/null +++ b/internal/solvers/redstoneoev/chainreader_test.go @@ -0,0 +1,263 @@ +package redstoneoev + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/api/bindings/liquidlane/adapter" + "github.com/symbioticfi/vault-solver/api/bindings/oev/aggregator" + "github.com/symbioticfi/vault-solver/internal/chain" +) + +// mustParseABI parses a binding's committed ABI JSON, panicking on a malformed (static) fragment — for the +// byte-crafting test helpers below. Protocol ABIs live in the owning solver package. +func mustParseABI(j string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(j)) + if err != nil { + panic("redstoneoev: parse abi: " + err.Error()) + } + return parsed +} + +// Parsed ABIs derived from the v2 bindings' committed MetaData, used only by the byte-crafting test helpers +// (isCall / packOut) to recognize a packed sub-call's selector and to ABI-encode a method's RETURN values — +// the production reader packs/decodes through the binding's typed PackXxx/UnpackXxx. Same source of record +// as the bindings, so selectors/output shapes can't drift from what the reader sends. +var ( + aggABI = mustParseABI(aggregator.AggregatorV3MetaData.ABI) + adapterABI = mustParseABI(adapter.LiquidLaneAdapterMetaData.ABI) +) + +// packOut ABI-encodes a method's RETURN values, so a test can craft the bytes a Multicall sub-call would +// hand back — lets the pure snapshot decoders be tested with no RPC. +func packOut(t *testing.T, a abi.ABI, method string, vals ...any) []byte { + t.Helper() + out, err := a.Methods[method].Outputs.Pack(vals...) + if err != nil { + t.Fatalf("pack %s outputs: %v", method, err) + } + return out +} + +// TestBuildQuotePausedFailClosed pins that unreadable paused() state fails closed to no quote. A successful +// paused() returning false still yields a quote. +func TestBuildQuotePausedFailClosed(t *testing.T) { + loan := common.HexToAddress("0x0000000000000000000000000000000000000010") + coll := common.HexToAddress("0x0000000000000000000000000000000000000011") + decs := map[common.Address]int{loan: 6, coll: 18} + rateRes := chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "getMaxRate", mustBig("1800000000000000000000"))} + maxAssetsRes := chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "getMaxAssets", big.NewInt(1_000_000_000_000))} + + // Control: paused() succeeds and is false → a quote is built. + pausedFalse := chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "paused", false)} + if q := buildQuote(rateRes, maxAssetsRes, pausedFalse, decs, loan, coll); q == nil { + t.Fatal("paused()=false with good rate/liquidity should yield a quote") + } + // Reverted paused() sub-call → unknown pause state → no quote (fail closed). + if q := buildQuote(rateRes, maxAssetsRes, chain.CallResult{Success: false}, decs, loan, coll); q != nil { + t.Fatal("an unsuccessful paused() sub-call must fail closed (no quote)") + } + // Successful but undecodable paused() bytes → no quote (fail closed). + garbled := chain.CallResult{Success: true, ReturnData: []byte{0x01, 0x02}} + if q := buildQuote(rateRes, maxAssetsRes, garbled, decs, loan, coll); q != nil { + t.Fatal("an undecodable paused() return must fail closed (no quote)") + } +} + +// TestFillerAuth covers the canonical 3-way single-adapter authorization (callback==marketMaker || +// callback==owner || isFiller(marketMaker, callback)): direct ownership/market-making authorizes without a +// second call, a resolved marketMaker queues isFiller, and an unresolved marketMaker fails closed. +func TestFillerAuth(t *testing.T) { + callback := common.HexToAddress("0x00000000000000000000000000000000000000cb") + mm := common.HexToAddress("0x0000000000000000000000000000000000000071") // a normal market maker (not the callback) + + mkRes := func(mmAddr, ownerAddr common.Address, ok bool) []chain.CallResult { + if !ok { + return []chain.CallResult{{Success: false}, {Success: false}} + } + return []chain.CallResult{ + {Success: true, ReturnData: packOut(t, adapterABI, "marketMaker", mmAddr)}, + {Success: true, ReturnData: packOut(t, adapterABI, "owner", ownerAddr)}, + } + } + + // callback IS the marketMaker → direct, no isFiller. + if auth, _, need := resolveFillerAuth(callback, mkRes(callback, mm, true)); !auth || need { + t.Fatalf("callback as marketMaker must authorize directly (auth=%v need=%v)", auth, need) + } + // callback IS the owner → direct, no isFiller. + if auth, _, need := resolveFillerAuth(callback, mkRes(mm, callback, true)); !auth || need { + t.Fatalf("callback as owner must authorize directly (auth=%v need=%v)", auth, need) + } + // resolved marketMaker but not direct → needs isFiller(mm, callback). + auth, gotMM, need := resolveFillerAuth(callback, mkRes(mm, mm, true)) + if auth || !need || gotMM != mm { + t.Fatalf("delegated adapter must defer to isFiller (auth=%v need=%v mm=%s)", auth, need, gotMM) + } + // unresolved marketMaker + not owned → fail closed (no isFiller round). + deadAuth, _, deadNeed := resolveFillerAuth(callback, mkRes(common.Address{}, mm, false)) + if deadAuth || deadNeed { + t.Fatalf("an unresolved marketMaker must fail closed (auth=%v need=%v)", deadAuth, deadNeed) + } +} + +func TestFeedDecimalsInBounds(t *testing.T) { + cases := []struct { + name string + loanDec, ethDec uint8 + want bool + }{ + {"chainlink usd pairs", 8, 8, true}, + {"exactly at bound", maxFeedDecimals, maxFeedDecimals, true}, + {"loan over bound", maxFeedDecimals + 1, 8, false}, + {"eth over bound", 8, maxFeedDecimals + 1, false}, + {"uint8 max", 255, 255, false}, + } + for _, c := range cases { + if got := feedDecimalsInBounds(c.loanDec, c.ethDec); got != c.want { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} + +func TestFeedFresh(t *testing.T) { + const now, maxAge = 1_000_000, 3600 + cases := []struct { + name string + updatedAt int64 + want bool + }{ + {"current", now, true}, + {"recent", now - 1800, true}, + {"exactly max age", now - maxAge, true}, + {"just stale", now - maxAge - 1, false}, + {"future", now + 1, false}, + } + for _, c := range cases { + if got := feedFresh(c.updatedAt, now, maxAge); got != c.want { + t.Errorf("%s: feedFresh = %v, want %v", c.name, got, c.want) + } + } +} + +func TestAggregatorFeedDecoders(t *testing.T) { + latest := packOut(t, aggABI, "latestRoundData", + big.NewInt(10), mustBig("250000000000"), big.NewInt(900), big.NewInt(1_000), big.NewInt(10)) + answer, updatedAt, err := decodeLatestRoundData(latest) + if err != nil { + t.Fatal(err) + } + if answer.String() != "250000000000" || updatedAt.Int64() != 1_000 { + t.Fatalf("latestRoundData decoded answer=%s updatedAt=%s", answer, updatedAt) + } + + dec, err := decodeDecimals(packOut(t, aggABI, "decimals", uint8(8))) + if err != nil { + t.Fatal(err) + } + if dec != 8 { + t.Fatalf("decimals=%d, want 8", dec) + } + + if _, _, err := decodeLatestRoundData([]byte{0x01, 0x02}); err == nil { + t.Fatal("garbled latestRoundData must fail") + } + if _, err := decodeDecimals([]byte{0x01, 0x02}); err == nil { + t.Fatal("garbled decimals must fail") + } +} + +// TestDecodeRedeemTokens pins the redeemable-collateral decode: every tokensToRedeem(i) sub-call must +// succeed and decode to a non-zero address, else the whole read fails closed (ok=false) so a partial set +// never narrows market discovery to a wrong subset. +func TestDecodeRedeemTokens(t *testing.T) { + tA := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + tB := common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + okRes := func(addr common.Address) chain.CallResult { + return chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "tokensToRedeem", addr)} + } + + t.Run("all decode", func(t *testing.T) { + toks, ok := decodeRedeemTokens([]chain.CallResult{okRes(tA), okRes(tB)}, 2) + if !ok || len(toks) != 2 || toks[0] != tA || toks[1] != tB { + t.Fatalf("ok=%v toks=%+v", ok, toks) + } + }) + t.Run("a reverted entry fails closed", func(t *testing.T) { + if _, ok := decodeRedeemTokens([]chain.CallResult{okRes(tA), {Success: false}}, 2); ok { + t.Fatal("a reverted sub-call must fail the whole read") + } + }) + t.Run("a zero address fails closed", func(t *testing.T) { + if _, ok := decodeRedeemTokens([]chain.CallResult{okRes(common.Address{})}, 1); ok { + t.Fatal("a zero-address token must fail the read") + } + }) + t.Run("length mismatch fails closed", func(t *testing.T) { + if _, ok := decodeRedeemTokens([]chain.CallResult{okRes(tA)}, 2); ok { + t.Fatal("a short result vector must fail the read") + } + }) +} + +func TestReadRedeemableCollateralsCachedReturnsCopy(t *testing.T) { + adapter := common.HexToAddress("0x00000000000000000000000000000000000000aa") + coll := common.HexToAddress("0x00000000000000000000000000000000000000bb") + changed := common.HexToAddress("0x00000000000000000000000000000000000000cc") + r := &reader{redeemColl: map[common.Address][]common.Address{adapter: {coll}}} + + got, err := r.readRedeemableCollaterals(context.Background(), adapter) + if err != nil || len(got) != 1 || got[0] != coll { + t.Fatalf("cached redeemable collaterals = (%v, %v), want [%s]", got, err, coll.Hex()) + } + got[0] = changed + again, err := r.readRedeemableCollaterals(context.Background(), adapter) + if err != nil || len(again) != 1 || again[0] != coll { + t.Fatalf("cached collateral slice was mutated: got (%v, %v), want [%s]", again, err, coll.Hex()) + } +} + +// TestDecodeRedeemCount pins getTokensToRedeemLength decoding: a valid length decodes, a reverted/absurd +// length fails closed. +func TestDecodeRedeemCount(t *testing.T) { + lenRes := func(n int64) chain.CallResult { + return chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "getTokensToRedeemLength", big.NewInt(n))} + } + if got, ok := decodeRedeemCount([]chain.CallResult{lenRes(3)}); !ok || got != 3 { + t.Fatalf("valid length: got=%d ok=%v", got, ok) + } + if _, ok := decodeRedeemCount([]chain.CallResult{{Success: false}}); ok { + t.Fatal("a reverted length read must fail closed") + } + if _, ok := decodeRedeemCount(nil); ok { + t.Fatal("an empty result vector must fail closed") + } +} + +// TestVerifyAdapterPair pins the pair half of the on-chain market verification: keep only markets whose +// loan == the adapter's loan AND whose collateral ∈ the adapter's redeemable set. +func TestVerifyAdapterPair(t *testing.T) { + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + other := common.HexToAddress("0x1111111111111111111111111111111111111111") + collA := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + collB := common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + collX := common.HexToAddress("0x2222222222222222222222222222222222222222") + + good := common.HexToHash("0xaa") // loan match + collateral in set + wrongL := common.HexToHash("0xbb") // wrong loan + wrongC := common.HexToHash("0xcc") // collateral not redeemable + params := map[common.Hash]abiMarketParams{ + good: {LoanToken: loan, CollateralToken: collA}, + wrongL: {LoanToken: other, CollateralToken: collB}, + wrongC: {LoanToken: loan, CollateralToken: collX}, + } + kept := verifyAdapterPair(params, loan, []common.Address{collA, collB}) + if len(kept) != 1 || kept[0] != good { + t.Fatalf("want exactly the matching pair, got %+v", kept) + } +} diff --git a/internal/solvers/redstoneoev/config_test.go b/internal/solvers/redstoneoev/config_test.go new file mode 100644 index 00000000..03f143bf --- /dev/null +++ b/internal/solvers/redstoneoev/config_test.go @@ -0,0 +1,299 @@ +package redstoneoev + +import ( + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "gopkg.in/yaml.v3" +) + +type exampleSolverEntry struct { + Name string `yaml:"name"` + Config yaml.Node `yaml:"config"` +} + +type exampleConfigFile struct { + Solvers []exampleSolverEntry `yaml:"solvers"` +} + +// TestExampleConfigParses loads the committed Sepolia profile and runs its solver block through +// parseConfig, so the example can't drift out of sync with the parser/validation. +func TestExampleConfigParses(t *testing.T) { + data, err := os.ReadFile("../../../config/redstone-oev.sepolia.example.yaml") + if err != nil { + t.Fatalf("read example config: %v", err) + } + var top exampleConfigFile + if err := yaml.Unmarshal(data, &top); err != nil { + t.Fatal(err) + } + if len(top.Solvers) != 1 || top.Solvers[0].Name != Name { + t.Fatalf("example must define exactly the %q solver, got %+v", Name, top.Solvers) + } + cfg, err := parseConfig(top.Solvers[0].Config) + if err != nil { + t.Fatalf("example config failed to parse: %v", err) + } + // Full liquidation is the production default; disabling it is the explicit fallback if settlement + // routing ever has issues with full-collateral/bad-debt cases. + if !cfg.Sizing.AllowFullLiquidation { + t.Fatal("example settings drifted: allowFullLiquidation must stay enabled") + } + if !cfg.hasRateSource() { + t.Fatal("example settings drifted: config must carry a loan↔ETH rate source") + } +} + +func decodeCfg(t *testing.T, y string) (*Config, error) { + t.Helper() + var node yaml.Node + if err := yaml.Unmarshal([]byte(y), &node); err != nil { + t.Fatal(err) + } + // The framework hands the solver the `config:` sub-node; here y is that node's content. + return parseConfig(node) +} + +// TestConfigProfiles is the deployment matrix: each representative operating configuration must parse and +// validate, and produce the Config the operator expects. This is the offline proof that "the various +// configurations are all operable as expected" — every mode/combination the solver supports, exercised +// through the real parser+validator (the on-chain behavior of each is the operator's live runbook). +func TestConfigProfiles(t *testing.T) { + cases := []struct { + name string + yaml string + check func(*testing.T, *Config) + }{ + { + // Production: the Morpho API is the market source + a flat bid, with a loan↔ETH rate source so + // the bundle-level after-cost profitability gate is active. + name: "prod: API snapshot / flat bid", + yaml: wsline + addrs + api + feedLine + okBid, + check: func(t *testing.T, c *Config) { + t.Helper() + if c.MorphoAPIURL == "" { + t.Fatal("prod profile must carry the Morpho API as its market source") + } + if c.BidWei.Sign() <= 0 { + t.Fatalf("prod profile must carry a positive flat bid, got %v", c.BidWei) + } + if !c.hasRateSource() { + t.Fatal("prod profile must carry a rate source") + } + }, + }, + { + name: "morphoApiUrl monitor: API URL + poll override", + yaml: wsline + addrs + "morphoApiUrl: https://api.morpho.org/graphql\n" + + feedLine + "intervals: {monitorPollMs: 10000}\n" + okBid, + check: func(t *testing.T, c *Config) { + t.Helper() + if c.MorphoAPIURL != "https://api.morpho.org/graphql" || c.MonitorPoll != 10*time.Second { + t.Fatalf("monitor profile wrong: url=%q poll=%v", c.MorphoAPIURL, c.MonitorPoll) + } + if c.DiscoveryMaxHealthFactor != 1.30 { // default at-risk band ceiling + t.Fatalf("discoveryMaxHealthFactor default wrong: %v", c.DiscoveryMaxHealthFactor) + } + }, + }, + { + name: "sizing: full liquidation can be disabled", + yaml: wsline + addrs + api + feedLine + "bid: {bidEth: \"0.0005\"}\nsizing: {allowFullLiquidation: false}", + check: func(t *testing.T, c *Config) { + t.Helper() + if c.Sizing.AllowFullLiquidation { + t.Fatal("allowFullLiquidation=false was not parsed") + } + }, + }, + { + name: "single adapter pinned + oracle rate source", + yaml: wsline + addrs + api + feedLine + okBid, + check: func(t *testing.T, c *Config) { + t.Helper() + if c.Adapter != adapterAddr || c.LoanEthFeed == nil { + t.Fatalf("single-adapter profile wrong: adapter=%s feed=%v", c.Adapter, c.LoanEthFeed) + } + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := decodeCfg(t, tc.yaml) + if err != nil { + t.Fatalf("profile failed to parse: %v", err) + } + tc.check(t, cfg) + }) + } +} + +const validCfg = ` +ws: + url: wss://dev-rwa-sepolia.oev.a.redstone.finance + apiKeyEnv: OEV_REDSTONE_API_KEY +executor: "0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD" +callback: "0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1" +adapter: "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b" +morphoApiUrl: https://api.morpho.org/graphql +loanEthFeed: + ethUsd: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + loanUsd: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + maxAgeMs: 3600000 +bid: + bidEth: "0.0005" + minBundleProfitBidBps: 1000 + totalBundleProfitBps: 500 + maxTxGasPriceWei: "60000000000" +sizing: + allowFullLiquidation: true + swapHaircutBps: 200 +intervals: + monitorPollMs: 15000 +` + +func TestParseConfigValid(t *testing.T) { + cfg, err := decodeCfg(t, validCfg) + if err != nil { + t.Fatal(err) + } + if cfg.BidWei.String() != "500000000000000" { // 0.0005 ETH + t.Fatalf("bidWei = %s", cfg.BidWei) + } + if !cfg.Sizing.AllowFullLiquidation || cfg.Sizing.SwapHaircutBps != 200 { + t.Fatalf("bad sizing: %+v", cfg.Sizing) + } + if cfg.MorphoAPIURL != "https://api.morpho.org/graphql" || cfg.MonitorPoll != 15*time.Second { + t.Fatalf("morphoApiUrl=%q monitorPoll=%v", cfg.MorphoAPIURL, cfg.MonitorPoll) + } + if cfg.MinBundleProfitBidBps != 1000 { + t.Fatalf("minBundleProfitBidBps=%d, want 1000", cfg.MinBundleProfitBidBps) + } + if cfg.TotalBundleProfitBps != 500 { + t.Fatalf("totalBundleProfitBps=%d, want 500", cfg.TotalBundleProfitBps) + } +} + +func TestParseConfigDefaults(t *testing.T) { + cfg, err := decodeCfg(t, ` +ws: {url: "wss://x", apiKeyEnv: K} +executor: "0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD" +callback: "0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1" +adapter: "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b" +morphoApiUrl: https://api.morpho.org/graphql +loanEthFeed: {ethUsd: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", loanUsd: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"} +bid: {bidEth: "0.0001"} +`) + if err != nil { + t.Fatal(err) + } + if cfg.Sizing.AllowFullLiquidation != defaultAllowFullLiquidation { + t.Fatalf("defaults not applied: allowFullLiquidation=%v", cfg.Sizing.AllowFullLiquidation) + } + if cfg.MonitorPoll != defaultMonitorPoll || cfg.MaxTxGasPrice.Int64() != defaultMaxTxGasPrice { + t.Fatalf("interval/gas defaults wrong") + } + if cfg.MaxTrackedPositions != defaultMaxTrackedPositions { + t.Fatalf("maxTrackedPositions default wrong: %d, want %d", cfg.MaxTrackedPositions, defaultMaxTrackedPositions) + } +} + +// TestParseConfigMaxTrackedPositions pins the cap knob: unset → default; an explicit positive value is +// honored; 0 and negative are rejected (it doubles as the GraphQL `first` arg). +func TestParseConfigMaxTrackedPositions(t *testing.T) { + t.Run("explicit positive honored", func(t *testing.T) { + cfg, err := decodeCfg(t, wsline+addrs+api+feedLine+okBid+"maxTrackedPositions: 50\n") + if err != nil { + t.Fatal(err) + } + if cfg.MaxTrackedPositions != 50 { + t.Fatalf("maxTrackedPositions = %d, want 50", cfg.MaxTrackedPositions) + } + }) + for _, bad := range []string{"0", "-1"} { + t.Run("rejects "+bad, func(t *testing.T) { + if _, err := decodeCfg(t, wsline+addrs+api+feedLine+okBid+"maxTrackedPositions: "+bad+"\n"); err == nil { + t.Fatalf("expected error for maxTrackedPositions: %s", bad) + } + }) + } +} + +// TestParseConfigSwapHaircutZeroRespected pins the *int handling: an explicit swapHaircutBps:0 (no +// extra haircut) must survive parsing, not be silently replaced by the 2% default. +func TestParseConfigSwapHaircutZeroRespected(t *testing.T) { + cfg, err := decodeCfg(t, wsline+addrs+api+feedLine+"bid: {bidEth: \"0.1\"}\nsizing: {swapHaircutBps: 0}") + if err != nil { + t.Fatal(err) + } + if cfg.Sizing.SwapHaircutBps != 0 { + t.Fatalf("explicit swapHaircutBps:0 should be respected, got %d", cfg.Sizing.SwapHaircutBps) + } + // And unset still defaults to 2%. + cfg2, err := decodeCfg(t, wsline+addrs+api+feedLine+"bid: {bidEth: \"0.1\"}") + if err != nil { + t.Fatal(err) + } + if cfg2.Sizing.SwapHaircutBps != defaultSwapHaircut { + t.Fatalf("unset swapHaircutBps should default to %d, got %d", defaultSwapHaircut, cfg2.Sizing.SwapHaircutBps) + } +} + +func TestParseConfigErrors(t *testing.T) { + cases := map[string]string{ + "missing ws url": `ws: {apiKeyEnv: K}` + "\n" + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}", + "missing apiKeyEnv": `ws: {url: x}` + "\n" + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}", + "removed positionSource": wsline + addrs + api + feedLine + "positionSource: redstone\nbid: {bidEth: \"0.1\"}", // unknown key: knob removed + "removed markets key": wsline + addrs + api + feedLine + `markets: ["` + mkt + `"]` + "\nbid: {bidEth: \"0.1\"}", // markets no longer a config field → unknown key + "zero bid": wsline + addrs + api + feedLine + "bid: {bidEth: \"0\"}", + "bad executor addr": wsline + `executor: "0xnope"` + "\ncallback: \"0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1\"\n" + api + feedLine + "bid: {bidEth: \"0.1\"}", + "missing loanEthFeed": wsline + addrs + api + "bid: {bidEth: \"0.1\"}", + "removed maxSeizeFractionBps": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nsizing: {maxSeizeFractionBps: 9000}", + "removed maxLegsPerBid": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", maxLegsPerBid: 8}", + "removed minLegProfitLoan": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nsizing: {minLegProfitLoan: \"1\"}", + "negative swapHaircutBps": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nsizing: {swapHaircutBps: -1}", + "bad morphoApiUrl": wsline + addrs + "morphoApiUrl: \"not-a-url\"\n" + feedLine + "bid: {bidEth: \"0.1\"}", + "non-positive maxHF": wsline + addrs + api + feedLine + "discoveryMaxHealthFactor: 0\nbid: {bidEth: \"0.1\"}", + "removed gasBase": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", gasBase: 100000}", + "removed gasPerLeg": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", gasPerLeg: 800000}", + "removed loanPerEth": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", loanPerEth: \"2500000000\"}", + "bad loan feed age": wsline + addrs + api + "loanEthFeed: {ethUsd: \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\", loanUsd: \"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\", maxAgeMs: 0}\nbid: {bidEth: \"0.1\"}", + "removed minBundleProfitLoan": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", minBundleProfitLoan: \"1\"}", + "negative minBundleProfitBidBps": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", minBundleProfitBidBps: -1}", + "bad totalBundleProfitBps": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", totalBundleProfitBps: 10001}", + "zero maxTxGasPrice": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", maxTxGasPriceWei: \"0\"}", + "removed gas multiplier": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", gasPriceMultiplierBps: 20000}", + "removed priority fee": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", priorityFeeWei: \"1\"}", + "removed market poll": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {marketPollMs: 5000}", + "removed position poll": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {positionPollMs: 2000}", + "negative interval": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {monitorPollMs: -1}", + "removed discovery poll": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {discoveryPollMs: 10000}", + "removed snapshot age": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nmaxSnapshotAgeMs: 60000", + "zero interval": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {opsPollMs: 0}", + "non-positive breaker": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nbreaker: {maxFailures: 3, windowMs: 0}", + } + for name, y := range cases { + t.Run(name, func(t *testing.T) { + if _, err := decodeCfg(t, y); err == nil { + t.Fatalf("expected error for %q", name) + } + }) + } +} + +const ( + mkt = "0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5" + wsline = "ws: {url: x, apiKeyEnv: K}\n" + addrs = "executor: \"0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD\"\n" + + "callback: \"0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1\"\n" + + "adapter: \"0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b\"\n" + // api is the production market source (the Morpho API) appended to a valid config; markets/positions are + // discovered at runtime, so a parseable config needs no market list. + api = "morphoApiUrl: https://api.morpho.org/graphql\n" + feedLine = "loanEthFeed: {ethUsd: \"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\", loanUsd: \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\", maxAgeMs: 3600000}\n" + okBid = "bid: {bidEth: \"0.0005\"}\n" +) + +var adapterAddr = common.HexToAddress("0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b") diff --git a/internal/solvers/redstoneoev/eip191_test.go b/internal/solvers/redstoneoev/eip191_test.go new file mode 100644 index 00000000..c9162569 --- /dev/null +++ b/internal/solvers/redstoneoev/eip191_test.go @@ -0,0 +1,72 @@ +package redstoneoev + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// TestExecutorV6DigestGoldenVector pins the digest computation to the verified live vector +// (docs/OEV-PLAN.md §6.7): the same inputs that produced a winning, signature-valid bid on Sepolia. +func TestExecutorV6DigestGoldenVector(t *testing.T) { + chainID := big.NewInt(11155111) + callback := common.HexToAddress("0x812492C36b003837C30cB0B63960b86eC9B27309") + opDataHash := common.HexToHash("0x0a85a1be3cf06539edd05476a60cca5482e8ef0c4fa0bb6c1cf3f79fd0945509") + bid := big.NewInt(100000000000000) // 0.0001 ETH + nonce := big.NewInt(1) + maxGas := big.NewInt(50000000000) // 50 gwei + + got, err := ExecutorV6Digest(chainID, callback, opDataHash, bid, nonce, maxGas) + if err != nil { + t.Fatal(err) + } + want := common.HexToHash("0x78f6eb68948cfeb1e16a81b050c111bf099628ff9dc51debb55f0b4fff2c7e5a") + if got != want { + t.Fatalf("digest = %s, want %s", got.Hex(), want.Hex()) + } +} + +// TestSignBidRecoversToSigner round-trips signing + recovery with a throwaway key: the EIP-191 +// wrapping + signature must recover to the signer, exactly as the Executor's +// ECDSA.recover(toEthSignedMessageHash(digest)) does on-chain. +func TestSignBidRecoversToSigner(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + s := &testSigner{key: key, addr: crypto.PubkeyToAddress(key.PublicKey)} + + chainID := big.NewInt(11155111) + callback := common.HexToAddress("0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1") + opData := []byte{0x12, 0x34} + bid := big.NewInt(300000000000000) + nonce := big.NewInt(3) + maxGas := big.NewInt(60000000000) + + sig, err := SignBid(s, chainID, callback, opData, bid, nonce, maxGas) + if err != nil { + t.Fatal(err) + } + if len(sig) != 65 { + t.Fatalf("sig length = %d, want 65", len(sig)) + } + + digest, _ := ExecutorV6Digest(chainID, callback, crypto.Keccak256Hash(opData), bid, nonce, maxGas) + ethHash := ethSignedMessageHash(digest) + + // Recover: normalize v from {27,28} back to {0,1} for crypto.SigToPub. + rs := make([]byte, 65) + copy(rs, sig) + if rs[64] >= 27 { + rs[64] -= 27 + } + pub, err := crypto.SigToPub(ethHash.Bytes(), rs) + if err != nil { + t.Fatal(err) + } + if got := crypto.PubkeyToAddress(*pub); got != s.addr { + t.Fatalf("recovered %s, want %s", got.Hex(), s.addr.Hex()) + } +} diff --git a/internal/solvers/redstoneoev/gaspredictor_test.go b/internal/solvers/redstoneoev/gaspredictor_test.go new file mode 100644 index 00000000..3e3f378f --- /dev/null +++ b/internal/solvers/redstoneoev/gaspredictor_test.go @@ -0,0 +1,235 @@ +package redstoneoev + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestGasUnitsForBundleRoutes(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + bundle := chosenBundle{ + legs: []LiquidationLeg{{SwapAmountOut: big.NewInt(100)}}, + collaterals: []common.Address{coll}, + } + + cases := []struct { + name string + st *gasPredictorState + want uint64 + }{ + { + name: "unknown snapshot uses conservative code fallback", + st: nil, + want: fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstUnknownLeg, + }, + { + name: "acquire-only", + st: &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(100)}, + }, + want: fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAcquireLeg, + }, + { + name: "allocate from free assets", + st: &gasPredictorState{ + FreeAssets: big.NewInt(100), + Withdrawable: big.NewInt(100), + Acquire: map[common.Address]*big.Int{}, + }, + want: fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAllocateLeg, + }, + { + name: "deallocate before allocate", + st: &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(100), + Acquire: map[common.Address]*big.Int{}, + }, + want: fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstDeallocateLeg, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := gasPredictionForBundle(bundle, c.st).Units; got != c.want { + t.Fatalf("gasPredictionForBundle units = %d, want %d", got, c.want) + } + }) + } +} + +func TestGasUnitsForBundleConsumesSharedBudgets(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + bundle := chosenBundle{ + legs: []LiquidationLeg{ + {SwapAmountOut: big.NewInt(70)}, + {SwapAmountOut: big.NewInt(70)}, + {SwapAmountOut: big.NewInt(70)}, + }, + collaterals: []common.Address{coll, coll, coll}, + } + st := &gasPredictorState{ + FreeAssets: big.NewInt(80), + Withdrawable: big.NewInt(200), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(100)}, + } + want := fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAcquireLeg + gasAdditionalAllocateLeg + gasAdditionalDeallocateLeg + pred := gasPredictionForBundle(bundle, st) + if got := pred.Units; got != want { + t.Fatalf("gasPredictionForBundle units = %d, want %d", got, want) + } + if got := gasRoutesString(pred.Routes); got != "acquire,allocate,deallocate" { + t.Fatalf("routes = %q", got) + } + // The estimator must not mutate the cached predictor snapshot; buildBid reads it lock-free across bids. + if st.Acquire[coll].String() != "100" || st.FreeAssets.String() != "80" || st.Withdrawable.String() != "200" { + t.Fatalf("predictor mutated input state: %+v", st) + } +} + +func TestGasPredictionFixedFeedCostAndLimit(t *testing.T) { + bundle := chosenBundle{legs: []LiquidationLeg{ + {SwapAmountOut: big.NewInt(1)}, + {SwapAmountOut: big.NewInt(1)}, + }} + pred := gasPredictionForBundleFeeds(bundle, nil, 3) + want := gasBaseUnits + gasExecutorDebitSurcharge + 3*gasPriceUpdatePerFeed + gasFirstUnknownLeg + gasAdditionalUnknownLeg + if pred.Units != want { + t.Fatalf("gas with feed updates = %d, want %d", pred.Units, want) + } + if got, limitWant := usableBundleGasLimit(30_000_000), uint64(1_700_000); got != limitWant { + t.Fatalf("usableBundleGasLimit = %d, want %d", got, limitWant) + } + if got, limitWant := usableBundleGasLimit(1_000_000), uint64(850_000); got != limitWant { + t.Fatalf("small-chain usableBundleGasLimit = %d, want %d", got, limitWant) + } +} + +func TestLiveRedStoneLimitRejectsThreeAllocateLegs(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + two := chosenBundle{ + legs: []LiquidationLeg{ + {SwapAmountOut: big.NewInt(1)}, + {SwapAmountOut: big.NewInt(1)}, + }, + collaterals: []common.Address{coll, coll}, + } + three := chosenBundle{ + legs: []LiquidationLeg{ + {SwapAmountOut: big.NewInt(1)}, + {SwapAmountOut: big.NewInt(1)}, + {SwapAmountOut: big.NewInt(1)}, + }, + collaterals: []common.Address{coll, coll, coll}, + } + four := chosenBundle{ + legs: []LiquidationLeg{ + {SwapAmountOut: big.NewInt(1)}, + {SwapAmountOut: big.NewInt(1)}, + {SwapAmountOut: big.NewInt(1)}, + {SwapAmountOut: big.NewInt(1)}, + }, + collaterals: []common.Address{coll, coll, coll, coll}, + } + st := &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{}, + } + + if !bundleFitsGasLimit(two, st, 2_000_000, 3) { + t.Fatal("two allocate legs should fit the observed RedStone settlement gas limit") + } + if !bundleFitsGasLimit(three, st, 2_000_000, 3) { + t.Fatal("three allocate legs should fit the observed RedStone settlement gas limit") + } + if bundleFitsGasLimit(four, st, 2_000_000, 3) { + t.Fatal("four allocate legs must not fit the observed RedStone settlement gas limit") + } +} + +func TestGasPredictionTracksForkCalibratedSettlements(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + bundle := func(legs int) chosenBundle { + b := chosenBundle{ + legs: make([]LiquidationLeg, legs), + collaterals: make([]common.Address, legs), + } + for i := range legs { + b.legs[i] = LiquidationLeg{SwapAmountOut: big.NewInt(1)} + b.collaterals[i] = coll + } + return b + } + cases := []struct { + name string + legs int + state *gasPredictorState + debitGas uint64 + }{ + { + name: "one acquire leg", + legs: 1, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(10)}, + }, + debitGas: 469_911, + }, + { + name: "two acquire legs", + legs: 2, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(10)}, + }, + debitGas: 588_048, + }, + { + name: "one allocate leg", + legs: 1, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{}, + }, + debitGas: 703_664, + }, + { + name: "two allocate legs", + legs: 2, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{}, + }, + debitGas: 969_948, + }, + { + name: "mixed acquire then allocate", + legs: 2, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(1)}, + }, + debitGas: 817_877, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + predicted := gasPredictionForBundleFeeds(bundle(c.legs), c.state, defaultPriceUpdateFeeds).Units + if predicted < c.debitGas { + t.Fatalf("predicted gas %d below debit gas %d", predicted, c.debitGas) + } + if predicted > c.debitGas*115/100 { + t.Fatalf("predicted gas %d too far above debit gas %d", predicted, c.debitGas) + } + }) + } +} diff --git a/internal/solvers/redstoneoev/live_fork_payload_test.go b/internal/solvers/redstoneoev/live_fork_payload_test.go new file mode 100644 index 00000000..33b94192 --- /dev/null +++ b/internal/solvers/redstoneoev/live_fork_payload_test.go @@ -0,0 +1,129 @@ +//go:build live + +package redstoneoev + +import ( + "context" + "encoding/json" + "math/big" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/chain" + appconfig "github.com/symbioticfi/vault-solver/internal/config" + "github.com/symbioticfi/vault-solver/internal/signer" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +type forkPayload struct { + Callback string `json:"callback"` + Executor string `json:"executor"` + Signer string `json:"signer"` + AuctionID string `json:"auctionId"` + BidWei string `json:"bidWei"` + Nonce string `json:"nonce"` + MaxTxGasPrice string `json:"maxTxGasPrice"` + OperationData string `json:"operationData"` + LiquidationSig string `json:"liquidationSig"` + LiquidateCalldata string `json:"liquidateCalldata"` + PayBidCalldata string `json:"payBidCalldata"` + Borrowers []string `json:"borrowers"` +} + +// TestLiveSepoliaDumpForkPayload writes /tmp/oev-fork-payload.json for an anvil-fork settlement replay. +// +// set -a; . ./.env.local; set +a +// OEV_TEST_MONITOR=true OEV_ONCHAIN_PRICE_FOR_TEST=true \ +// OEV_TEST_MARKETS=... OEV_TEST_POSITIONS=... \ +// go test -tags live ./internal/solvers/redstoneoev -run TestLiveSepoliaDumpForkPayload -v +func TestLiveSepoliaDumpForkPayload(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cfgPath := getenvDefault("OEV_CONFIG", "../../../config/redstone-oev.sepolia.example.yaml") + cfg, err := appconfig.Load(cfgPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if len(cfg.Solvers) != 1 || cfg.Solvers[0].Name != Name { + t.Fatalf("expected single %s solver in %s", Name, cfgPath) + } + chainClient, err := chain.Dial(ctx, []string{cfg.Chain.RPCURL}, cfg.Chain.MulticallAddress, logr.Discard()) + if err != nil { + t.Fatalf("dial chain: %v", err) + } + defer chainClient.Close() + sgnr, err := signer.FromConfig(cfg.Signer) + if err != nil { + t.Fatalf("load signer: %v", err) + } + built, err := factory(cfg.Solvers[0].Config, solver.Deps{Chain: chainClient, Signer: sgnr, Log: logr.Discard()}) + if err != nil { + t.Fatalf("build solver: %v", err) + } + s, ok := built.(*Solver) + if !ok { + t.Fatalf("unexpected solver type %T", built) + } + s.refreshState(ctx) + s.mon.refresh(ctx) + snap := s.mon.snapshot() + if snap == nil || len(snap.prices) == 0 { + t.Fatalf("empty monitor snapshot") + } + + prices := make(map[string]string, len(snap.prices)) + for id, price := range snap.prices { + oracle := snap.markets[id].Params.Oracle + prices[oracle.Hex()] = price.String() + } + auction := AuctionMessage{ + Op: "auction", + ID: "fork-debug-" + time.Now().UTC().Format("20060102T150405Z"), + Timestamp: int64(snap.blockTime) * 1000, + Payload: AuctionPayload{Prices: prices}, + } + decision := s.buildBid(auction, func() time.Time { return time.Unix(int64(snap.blockTime), 0) }) + if decision.skip != "" { + t.Fatalf("buildBid skipped: %s gross=%s", decision.skip, decision.gross) + } + + opData, err := hexutil.Decode(decision.solve.Data.OperationData) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + bid := new(big.Int).Set(decision.bidNative) + out := forkPayload{ + Callback: s.cfg.Callback.Hex(), + Executor: s.cfg.Executor.Hex(), + Signer: sgnr.Address().Hex(), + AuctionID: auction.ID, + BidWei: bid.String(), + Nonce: decision.solve.Data.Nonce, + MaxTxGasPrice: decision.solve.Data.MaxTxGasPrice, + OperationData: decision.solve.Data.OperationData, + LiquidationSig: decision.solve.Data.LiquidationSig, + LiquidateCalldata: hexutil.Encode(callbackB.PackLiquidate(bid, sgnr.Address(), opData)), + PayBidCalldata: hexutil.Encode(callbackB.PackPayBid(bid)), + Borrowers: decision.solve.Data.Borrowers, + } + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + if err := os.WriteFile("/tmp/oev-fork-payload.json", raw, 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + t.Logf("wrote /tmp/oev-fork-payload.json: nonce=%s legs=%d maxTxGasPrice=%s", out.Nonce, len(out.Borrowers), out.MaxTxGasPrice) +} + +func getenvDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/solvers/redstoneoev/live_test.go b/internal/solvers/redstoneoev/live_test.go new file mode 100644 index 00000000..72aff18e --- /dev/null +++ b/internal/solvers/redstoneoev/live_test.go @@ -0,0 +1,113 @@ +//go:build live + +// Live read-only checks for the production Morpho API monitor path. They are OPT-IN (`-tags live`) and +// never run in the normal gate. +package redstoneoev + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" +) + +// TestLiveAPIMonitorSnapshotAndCandidates exercises the same production API path the OEV monitor uses: +// adapter-derived token pair -> Morpho markets with state -> monitor snapshot validation -> positions -> +// hot-path candidates. It uses a known mainnet USDC/PAXG pair as the adapter-derived stand-in; no RPC or +// real adapter is needed because this test targets the API-backed Morpho side. +// +// go test -tags live -run TestLiveAPIMonitorSnapshotAndCandidates -v ./internal/solvers/redstoneoev/ +func TestLiveAPIMonitorSnapshotAndCandidates(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") // USDC + coll := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") // PAXG + wantMarket := common.HexToHash("0x8eaf7b29f02ba8d8c1d7aeb587403dcb16e2e943e4e2f5f94b0963c2386406c9") + + mon := &apiMonitor{ + api: newMorphoClient("https://api.morpho.org/graphql"), + maxPositions: 100, + maxHF: 1.30, + log: logr.Discard(), + } + + apiMarkets, err := mon.api.DiscoverMarketData(ctx, 1, []common.Address{loan}, []common.Address{coll}) + if err != nil { + t.Fatalf("DiscoverMarketData live API failed: %v", err) + } + apiSnap := mon.apiMarketSnapshot(apiMarkets, loan, []common.Address{coll}, true) + if len(apiSnap.markets) == 0 { + t.Fatal("apiMonitor snapshot has no usable USDC/PAXG markets") + } + if _, ok := apiSnap.markets[wantMarket]; !ok { + t.Fatalf("apiMonitor snapshot missing known market %s (got %d markets)", wantMarket.Hex(), len(apiSnap.markets)) + } + if apiSnap.block == 0 || apiSnap.blockTime == 0 { + t.Fatalf("apiMonitor snapshot missing epoch: block=%d blockTime=%d", apiSnap.block, apiSnap.blockTime) + } + for id, info := range apiSnap.markets { + if info.Params.LoanToken != loan || info.Params.CollateralToken != coll || info.Params.Oracle == (common.Address{}) { + t.Fatalf("bad market params for %s: %+v", id.Hex(), info.Params) + } + if got, err := deriveMarketID(info.Params); err != nil || got != id { + t.Fatalf("market id verification failed for %s: derived=%s err=%v", id.Hex(), got.Hex(), err) + } + if _, ok := apiSnap.prices[id]; !ok { + t.Fatalf("market %s missing API state price", id.Hex()) + } + } + + ids := make([]common.Hash, 0, len(apiSnap.markets)) + quotes := make(map[common.Hash]AdapterQuote, len(apiSnap.markets)) + for id := range apiSnap.markets { + ids = append(ids, id) + quotes[id] = newQuote("1780000000000000000000", nil) + } + apiPositions, err := mon.api.PositionsByMarket(ctx, ids, mon.maxPositions, &mon.maxHF) + if err != nil { + t.Fatalf("PositionsByMarket live API failed: %v", err) + } + positions := apiPositionsSnapshot(apiPositions, apiSnap.markets) + if len(positions) == 0 { + t.Skip("live API returned no USDC/PAXG positions inside healthFactor <= 1.30 right now") + } + + mon.snap.Store(&snapshot{ + markets: apiSnap.markets, prices: apiSnap.prices, quotes: quotes, positions: positions, + block: apiSnap.block, blockTime: apiSnap.blockTime, + }) + + var targetMarket common.Hash + var targetBorrower common.Address + for id, byBorrower := range positions { + for borrower := range byBorrower { + targetMarket, targetBorrower = id, borrower + break + } + if targetBorrower != (common.Address{}) { + break + } + } + oracle := apiSnap.markets[targetMarket].Params.Oracle + price := apiSnap.prices[targetMarket] + auction := AuctionMessage{Payload: AuctionPayload{Prices: map[string]string{oracle.Hex(): price.String()}}} + cands := mon.candidates(auction, apiSnap.blockTime) + if len(cands) == 0 { + t.Fatal("apiMonitor.candidates returned no candidates for a snapshot position with matching oracle price") + } + found := false + for _, c := range cands { + if c.cand.MarketID == targetMarket && c.cand.Borrower == targetBorrower && c.price.Cmp(price) == 0 { + found = true + break + } + } + if !found { + t.Fatalf("apiMonitor.candidates did not include target %s/%s", targetMarket.Hex(), targetBorrower.Hex()) + } + t.Logf("apiMonitor live snapshot: markets=%d positions=%d block=%d candidate=%s/%s", + len(apiSnap.markets), len(apiPositions), apiSnap.block, targetMarket.Hex(), targetBorrower.Hex()) +} diff --git a/internal/solvers/redstoneoev/monitor_test.go b/internal/solvers/redstoneoev/monitor_test.go new file mode 100644 index 00000000..2f8db530 --- /dev/null +++ b/internal/solvers/redstoneoev/monitor_test.go @@ -0,0 +1,217 @@ +package redstoneoev + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +func TestCandidatePriceSource(t *testing.T) { + id := common.HexToHash("0x01") + oracle := common.HexToAddress("0x00000000000000000000000000000000000000aa") + onchain := mustBig("1000000000000000000000000000000000000") + framePx := new(big.Int).Mul(onchain, big.NewInt(2)) + + snap := &snapshot{ + markets: map[common.Hash]MarketInfo{ + id: {Params: abiMarketParams{Oracle: oracle}, State: goldenMarket()}, + }, + prices: map[common.Hash]*big.Int{id: onchain}, + quotes: map[common.Hash]AdapterQuote{ + id: newQuote("1780000000000000000000", mustBig("100000000000")), + }, + positions: map[common.Hash]map[common.Address]morpho.PositionState{ + id: {common.Address{1}: goldenBorrower()}, + }, + } + auction := AuctionMessage{Payload: AuctionPayload{Prices: map[string]string{oracle.Hex(): framePx.String()}}} + + apiCands := candidatesFromAuction(logr.Discard(), snap, auction, snap.markets[id].State.LastUpdate) + if len(apiCands) != 1 || apiCands[0].price.Cmp(framePx) != 0 { + t.Fatalf("auction path price = %+v, want %v", apiCands, framePx) + } + + testCands := candidatesFromCachedPrices(snap, snap.markets[id].State.LastUpdate) + if len(testCands) != 1 || testCands[0].price.Cmp(onchain) != 0 { + t.Fatalf("cached-price path price = %+v, want %v", testCands, onchain) + } +} + +func TestCandidateRequiresAuctionPriceForMarketOracle(t *testing.T) { + id := common.HexToHash("0x01") + oracle := common.HexToAddress("0x00000000000000000000000000000000000000aa") + otherOracle := common.HexToAddress("0x00000000000000000000000000000000000000bb") + snap := &snapshot{ + markets: map[common.Hash]MarketInfo{ + id: {Params: abiMarketParams{Oracle: oracle}, State: goldenMarket()}, + }, + quotes: map[common.Hash]AdapterQuote{ + id: newQuote("1780000000000000000000", mustBig("100000000000")), + }, + positions: map[common.Hash]map[common.Address]morpho.PositionState{ + id: {common.Address{1}: goldenBorrower()}, + }, + } + auction := AuctionMessage{Payload: AuctionPayload{Prices: map[string]string{ + otherOracle.Hex(): "1000000000000000000000000000", + "not-an-address": "1000000000000000000000000000", + oracle.Hex(): "0", + }}} + + got := candidatesFromAuction(logr.Discard(), snap, auction, snap.markets[id].State.LastUpdate) + if len(got) != 0 { + t.Fatalf("market without positive auction price for its oracle must not produce candidates: %+v", got) + } +} + +func TestSnapshotFreshForAuction(t *testing.T) { + auctionAt := int64(1_000_000) + auction := AuctionMessage{Timestamp: auctionAt} + positioned := func(s snapshot) *snapshot { + market := common.Hash{1} + borrower := common.Address{2} + s.positions = map[common.Hash]map[common.Address]morpho.PositionState{ + market: { + borrower: {BorrowShares: big.NewInt(1), Collateral: big.NewInt(1)}, + }, + } + return &s + } + tests := []struct { + name string + snap *snapshot + want string + }{ + {"nil snapshot has no positions", nil, ""}, + {"empty snapshot needs no epoch", &snapshot{}, ""}, + {"positions need block", positioned(snapshot{}), skipStaleEpoch}, + {"positions need block time", positioned(snapshot{block: 1}), skipStaleEpoch}, + {"positions within auction lag are usable", positioned(snapshot{block: 1, blockTime: uint64(auctionAt / 1000)}), ""}, + {"positions older than auction lag are stale", positioned(snapshot{block: 1, blockTime: uint64(auctionAt/1000) - uint64(snapshotMaxAuctionLag/time.Second) - 1}), skipStaleEpoch}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := snapshotFreshForAuction(tc.snap, auction); got != tc.want { + t.Fatalf("snapshotFreshForAuction = %q, want %q", got, tc.want) + } + }) + } +} + +func TestMarketInfoFromAPI(t *testing.T) { + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + coll := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + oracle := common.HexToAddress("0x1234567890123456789012345678901234567890") + irm := common.HexToAddress("0x2222222222222222222222222222222222222222") + lltv := mustBig("860000000000000000") + id, err := deriveMarketID(abiMarketParams{LoanToken: loan, CollateralToken: coll, Oracle: oracle, Irm: irm, Lltv: lltv}) + if err != nil { + t.Fatalf("deriveMarketID: %v", err) + } + + view, ok := marketInfoFromAPI(morphoMarket{ + MarketID: id, + Oracle: oracle, + IRM: irm, + LLTV: lltv.String(), + LoanAsset: morphoAsset{ + Address: loan, + }, + CollateralAsset: &morphoAsset{ + Address: coll, + }, + State: &morphoMarketState{ + BlockNumber: "123", + BorrowAssets: "1000", + BorrowShares: "900", + SupplyAssets: "5000", + SupplyShares: "4500", + Timestamp: "456", + Price: "1000000000000000000000000000000000000", + }, + }) + if !ok { + t.Fatal("marketInfoFromAPI returned !ok") + } + if view.id != id || view.block != 123 || view.blockTime != 456 || view.price.String() != "1000000000000000000000000000000000000" { + t.Fatalf("bad id/block/blockTime/price: id=%s block=%d blockTime=%d price=%v", + view.id, view.block, view.blockTime, view.price) + } + info := view.info + if info.Params.LoanToken != loan || info.Params.CollateralToken != coll || info.Params.Oracle != oracle || info.Params.Irm != irm { + t.Fatalf("bad params: %+v", info.Params) + } + if info.State.TotalBorrowAssets.String() != "1000" || info.State.TotalBorrowShares.String() != "900" || + info.State.TotalSupplyAssets.String() != "5000" || info.State.TotalSupplyShares.String() != "4500" || + info.State.LastUpdate != 456 || info.State.BorrowRatePerSec.Sign() != 0 || info.State.Fee.Sign() != 0 { + t.Fatalf("bad state: %+v", info.State) + } +} + +func TestAPIMarketSnapshotKeepsLatestBlockOnly(t *testing.T) { + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + coll := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + lltv := mustBig("860000000000000000") + mk := func(oracle common.Address, block, ts string) morphoMarket { + params := abiMarketParams{LoanToken: loan, CollateralToken: coll, Oracle: oracle, Lltv: lltv} + id, err := deriveMarketID(params) + if err != nil { + t.Fatalf("deriveMarketID: %v", err) + } + return morphoMarket{ + MarketID: id, + Oracle: oracle, + LLTV: lltv.String(), + LoanAsset: morphoAsset{Address: loan}, + CollateralAsset: &morphoAsset{Address: coll}, + State: &morphoMarketState{ + BlockNumber: block, Timestamp: ts, + BorrowAssets: "1000", BorrowShares: "900", SupplyAssets: "5000", SupplyShares: "4500", + }, + } + } + old := mk(common.HexToAddress("0x1111111111111111111111111111111111111111"), "10", "120") + latest := mk(common.HexToAddress("0x2222222222222222222222222222222222222222"), "11", "132") + + snap := (&apiMonitor{log: logr.Discard()}).apiMarketSnapshot([]morphoMarket{old, latest}, loan, []common.Address{coll}, true) + if snap.block != 11 || snap.blockTime != 132 { + t.Fatalf("snapshot epoch = (%d,%d), want (11,132)", snap.block, snap.blockTime) + } + if _, ok := snap.markets[latest.MarketID]; !ok || len(snap.markets) != 1 { + t.Fatalf("latest-only markets = %+v, want exactly %s", snap.markets, latest.MarketID.Hex()) + } +} + +func TestAPIMarketAndPositionFailClosed(t *testing.T) { + if _, ok := marketInfoFromAPI(morphoMarket{ + MarketID: common.Hash{1}, + CollateralAsset: &morphoAsset{Address: common.Address{2}}, + State: &morphoMarketState{BlockNumber: "bad"}, + }); ok { + t.Fatal("bad market numbers must be rejected") + } + + if _, ok := positionStateFromAPI(morphoPosition{ + MarketID: common.Hash{1}, + Borrower: common.Address{2}, + BorrowShares: "not-a-number", + Collateral: "10", + }); ok { + t.Fatal("bad position numbers must be rejected") + } + + pos, ok := positionStateFromAPI(morphoPosition{ + MarketID: common.Hash{1}, + Borrower: common.Address{2}, + BorrowShares: "11", + Collateral: "22", + }) + if !ok || pos.BorrowShares.Cmp(big.NewInt(11)) != 0 || pos.Collateral.Cmp(big.NewInt(22)) != 0 { + t.Fatalf("bad parsed position: %+v ok=%v", pos, ok) + } +} diff --git a/internal/solvers/redstoneoev/morphoapi_test.go b/internal/solvers/redstoneoev/morphoapi_test.go new file mode 100644 index 00000000..aa421f93 --- /dev/null +++ b/internal/solvers/redstoneoev/morphoapi_test.go @@ -0,0 +1,289 @@ +package redstoneoev + +import ( + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +// newTestMorphoClient points a morphoClient at the given httptest server. +func newTestMorphoClient(url string) *morphoClient { return newMorphoClient(url) } + +// mktA is the market id requested in these tests; mktB is one never requested. The fixtures below mirror +// the LIVE Morpho schema (api.morpho.org/graphql): the output field is `marketId` (not uniqueKey). +var ( + apiMktA = common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5") + apiMktB = common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") +) + +type positionsGraphQLVars struct { + IDs []string `json:"ids"` + First int `json:"first"` + Skip int `json:"skip"` +} + +type positionsGraphQLRequest struct { + Variables positionsGraphQLVars `json:"variables"` +} + +// newJSONServer returns an httptest server replying with a fixed status + JSON body. +func newJSONServer(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) +} + +func TestMorphoClientDiscoverMarketData(t *testing.T) { + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + coll := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + + t.Run("normal response returns candidate ids", func(t *testing.T) { + body := `{"data":{"markets":{"items":[ + {"marketId":"` + apiMktA.Hex() + `"}, + {"marketId":"` + apiMktB.Hex() + `"} + ]}}}` + srv := newJSONServer(t, http.StatusOK, body) + defer srv.Close() + + got, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 1, []common.Address{loan}, []common.Address{coll}) + if err != nil { + t.Fatalf("DiscoverMarketData: %v", err) + } + if len(got) != 2 || got[0].MarketID != apiMktA || got[1].MarketID != apiMktB { + t.Fatalf("bad markets: %+v", got) + } + }) + + t.Run("graphql errors => error", func(t *testing.T) { + srv := newJSONServer(t, http.StatusOK, `{"errors":[{"message":"bad chain"}]}`) + defer srv.Close() + if _, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 1, []common.Address{loan}, []common.Address{coll}); err == nil { + t.Fatal("expected an error on non-empty graphql errors") + } + }) + + t.Run("http 500 => error", func(t *testing.T) { + srv := newJSONServer(t, http.StatusInternalServerError, `{}`) + defer srv.Close() + if _, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 1, []common.Address{loan}, []common.Address{coll}); err == nil { + t.Fatal("expected an error on HTTP 500") + } + }) + + t.Run("empty/zero marketId skipped, valid kept", func(t *testing.T) { + body := `{"data":{"markets":{"items":[ + {"marketId":""}, + {"marketId":"0x0000000000000000000000000000000000000000000000000000000000000000"}, + {"marketId":"` + apiMktA.Hex() + `"} + ]}}}` + srv := newJSONServer(t, http.StatusOK, body) + defer srv.Close() + + got, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 1, []common.Address{loan}, []common.Address{coll}) + if err != nil { + t.Fatalf("DiscoverMarketData: %v", err) + } + if len(got) != 1 || got[0].MarketID != apiMktA { + t.Fatalf("want exactly the one valid market, got %+v", got) + } + }) + + t.Run("request sends lowercased addresses and chainId_in", func(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"markets":{"items":[]}}}`) + })) + defer srv.Close() + + if _, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 11155111, []common.Address{loan}, []common.Address{coll}); err != nil { + t.Fatalf("DiscoverMarketData: %v", err) + } + if !strings.Contains(gotBody, strings.ToLower(loan.Hex())) || !strings.Contains(gotBody, strings.ToLower(coll.Hex())) { + t.Fatalf("request body missing lowercased loan/collateral: %s", gotBody) + } + queryBody := strings.NewReplacer(" ", "", "\n", "", "\t", "").Replace(gotBody) + if !strings.Contains(queryBody, "chainId_in") || !strings.Contains(gotBody, `"chains":[11155111]`) { + t.Fatalf("request body missing chainId_in scope: %s", gotBody) + } + }) + + t.Run("empty pair => no call", func(t *testing.T) { + // A request would dial 127.0.0.1:0 and fail; an empty loan or collateral set must short-circuit. + api := newMorphoClient("http://127.0.0.1:0") + if got, err := api.DiscoverMarketData(context.Background(), 1, nil, []common.Address{coll}); err != nil || got != nil { + t.Fatalf("empty loan: got=%+v err=%v", got, err) + } + if got, err := api.DiscoverMarketData(context.Background(), 1, []common.Address{loan}, nil); err != nil || got != nil { + t.Fatalf("empty collateral: got=%+v err=%v", got, err) + } + }) +} + +func TestMorphoClientPositions(t *testing.T) { + borrower := common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE") + item := `{ + "user":{"address":"` + borrower.Hex() + `"}, + "market":{"marketId":"` + apiMktA.Hex() + `"}, + "state":{ + "borrowShares":"34", + "collateral":"56" + }, + "healthFactor":1.2 + }` + + t.Run("bulk by market", func(t *testing.T) { + srv := newJSONServer(t, http.StatusOK, `{"data":{"marketPositions":{"items":[`+item+`]}}}`) + defer srv.Close() + maxHF := 1.3 + got, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), []common.Hash{apiMktA}, 10, &maxHF) + if err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(got) != 1 || got[0].MarketID != apiMktA || got[0].Borrower != borrower { + t.Fatalf("bad position parse: %+v", got) + } + if got[0].HealthFactor == nil || *got[0].HealthFactor != 1.2 || + got[0].BorrowShares != "34" || got[0].Collateral != "56" { + t.Fatalf("bad state parse: %+v", got[0]) + } + }) + + t.Run("missing state does not panic", func(t *testing.T) { + body := `{"data":{"marketPositions":{"items":[{ + "user":{"address":"` + borrower.Hex() + `"}, + "market":{"marketId":"` + apiMktA.Hex() + `"}, + "healthFactor":1.2 + }]}}}` + srv := newJSONServer(t, http.StatusOK, body) + defer srv.Close() + + got, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), []common.Hash{apiMktA}, 10, nil) + if err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(got) != 1 || got[0].BorrowShares != "" || got[0].Collateral != "" { + t.Fatalf("missing state should keep only identity/risk, got %+v", got) + } + if _, ok := positionStateFromAPI(got[0]); ok { + t.Fatal("missing state must fail closed before entering the monitor snapshot") + } + }) + + t.Run("bulk chunks live API request caps", func(t *testing.T) { + var calls []positionsGraphQLVars + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req positionsGraphQLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + calls = append(calls, req.Variables) + if len(req.Variables.IDs) > maxPositionMarketIDs || req.Variables.First > maxPositionsPage { + _, _ = io.WriteString(w, `{"errors":[{"message":"Input validation failed"}]}`) + return + } + _, _ = io.WriteString(w, `{"data":{"marketPositions":{"items":[]}}}`) + })) + defer srv.Close() + + ids := make([]common.Hash, maxPositionMarketIDs+1) + for i := range ids { + ids[i] = common.BigToHash(big.NewInt(int64(i + 1))) + } + if _, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), ids, 10_000, nil); err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(calls) != 2 { + t.Fatalf("calls = %d, want 2 chunks", len(calls)) + } + if len(calls[0].IDs) != maxPositionMarketIDs || len(calls[1].IDs) != 1 { + t.Fatalf("bad id chunks: %d/%d", len(calls[0].IDs), len(calls[1].IDs)) + } + for _, c := range calls { + if c.First != maxPositionsPage || c.Skip != 0 { + t.Fatalf("bad page args: %+v", c) + } + } + }) + + t.Run("bulk paginates beyond one API page", func(t *testing.T) { + var skips []int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req positionsGraphQLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + skips = append(skips, req.Variables.Skip) + count := req.Variables.First + if req.Variables.Skip >= maxPositionsPage { + count = 1 + } + items := make([]string, count) + for i := range items { + addr := common.BigToAddress(big.NewInt(int64(req.Variables.Skip + i + 1))).Hex() + items[i] = `{"user":{"address":"` + addr + `"},"market":{"marketId":"` + apiMktA.Hex() + `"},"state":{"borrowShares":"1","collateral":"1"},"healthFactor":1.2}` + } + _, _ = io.WriteString(w, `{"data":{"marketPositions":{"items":[`+strings.Join(items, ",")+`]}}}`) + })) + defer srv.Close() + + got, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), []common.Hash{apiMktA}, maxPositionsPage+1, nil) + if err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(got) != maxPositionsPage+1 { + t.Fatalf("positions = %d, want %d", len(got), maxPositionsPage+1) + } + if len(skips) != 2 || skips[0] != 0 || skips[1] != maxPositionsPage { + t.Fatalf("skips = %+v, want [0 %d]", skips, maxPositionsPage) + } + }) + + t.Run("bulk truncates by global risk after market chunks", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req positionsGraphQLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + hf := "1.2" + chunkBorrower := common.HexToAddress("0x0000000000000000000000000000000000000001") + if len(req.Variables.IDs) == 1 { + hf = "1.01" + chunkBorrower = common.HexToAddress("0x0000000000000000000000000000000000000002") + } + chunkItem := `{"user":{"address":"` + chunkBorrower.Hex() + `"},"market":{"marketId":"` + apiMktA.Hex() + `"},"state":{"borrowShares":"1","collateral":"1"},"healthFactor":` + hf + `}` + _, _ = io.WriteString(w, `{"data":{"marketPositions":{"items":[`+chunkItem+`]}}}`) + })) + defer srv.Close() + + ids := make([]common.Hash, maxPositionMarketIDs+1) + for i := range ids { + ids[i] = common.BigToHash(big.NewInt(int64(i + 1))) + } + got, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), ids, 1, nil) + if err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(got) != 1 || got[0].Borrower != common.HexToAddress("0x0000000000000000000000000000000000000002") { + t.Fatalf("global top risk was not selected after chunk merge: %+v", got) + } + }) +} diff --git a/internal/solvers/redstoneoev/operationdata_decode_test.go b/internal/solvers/redstoneoev/operationdata_decode_test.go new file mode 100644 index 00000000..5662c1aa --- /dev/null +++ b/internal/solvers/redstoneoev/operationdata_decode_test.go @@ -0,0 +1,147 @@ +package redstoneoev + +import ( + "math/big" + "reflect" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" +) + +func decodeOperationData(data []byte) (operationData, error) { + vals, err := operationDataArgs.Unpack(data) + if err != nil { + return operationData{}, errors.Errorf("decode operationData: %w", err) + } + if len(vals) != 1 { + return operationData{}, errors.Errorf("decode operationData: got %d values, want 1", len(vals)) + } + if out, ok := vals[0].(operationData); ok { + return out, nil + } + return decodeOperationDataValue(reflect.ValueOf(vals[0])) +} + +func decodeOperationDataValue(v reflect.Value) (operationData, error) { + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return operationData{}, errors.Errorf("decode operationData: got %s, want struct", v.Kind()) + } + auth, err := decodeOperationAuthValue(v.FieldByName("Auth")) + if err != nil { + return operationData{}, err + } + legs, err := decodeOperationLegsValue(v.FieldByName("Legs")) + if err != nil { + return operationData{}, err + } + sigV := v.FieldByName("AuthSig") + sig, ok := sigV.Interface().([]byte) + if !ok { + return operationData{}, errors.Errorf("decode operationData: authSig has type %s", sigV.Type()) + } + return operationData{Auth: auth, Legs: legs, AuthSig: append([]byte(nil), sig...)}, nil +} + +func decodeOperationAuthValue(v reflect.Value) (operationAuth, error) { + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return operationAuth{}, errors.Errorf("decode operationData auth: got %s, want struct", v.Kind()) + } + key, ok := hashValue(v.FieldByName("AuctionKey")) + if !ok { + return operationAuth{}, errors.New("decode operationData auth: bad auctionKey") + } + bid, ok := bigValue(v.FieldByName("BidAmount")) + if !ok { + return operationAuth{}, errors.New("decode operationData auth: bad bidAmount") + } + minBundleProfit, ok := bigValue(v.FieldByName("MinBundleProfit")) + if !ok { + return operationAuth{}, errors.New("decode operationData auth: bad minBundleProfit") + } + return operationAuth{AuctionKey: key, BidAmount: bid, MinBundleProfit: minBundleProfit}, nil +} + +func decodeOperationLegsValue(v reflect.Value) ([]callbackLeg, error) { + if v.Kind() != reflect.Slice { + return nil, errors.Errorf("decode operationData legs: got %s, want slice", v.Kind()) + } + out := make([]callbackLeg, v.Len()) + for i := 0; i < v.Len(); i++ { + legV := v.Index(i) + if legV.Kind() == reflect.Pointer { + legV = legV.Elem() + } + if legV.Kind() != reflect.Struct { + return nil, errors.Errorf("decode operationData leg %d: got %s, want struct", i, legV.Kind()) + } + id, ok := hashValue(legV.FieldByName("MarketId")) + if !ok { + return nil, errors.Errorf("decode operationData leg %d: bad marketId", i) + } + borrower, ok := addressValue(legV.FieldByName("Borrower")) + if !ok { + return nil, errors.Errorf("decode operationData leg %d: bad borrower", i) + } + maxSeize, ok := bigValue(legV.FieldByName("MaxSeizeAssets")) + if !ok { + return nil, errors.Errorf("decode operationData leg %d: bad maxSeizeAssets", i) + } + minProfit, ok := bigValue(legV.FieldByName("MinProfit")) + if !ok { + return nil, errors.Errorf("decode operationData leg %d: bad minProfit", i) + } + out[i] = callbackLeg{MarketId: id, Borrower: borrower, MaxSeizeAssets: maxSeize, MinProfit: minProfit} + } + return out, nil +} + +func hashValue(v reflect.Value) (common.Hash, bool) { + if !v.IsValid() { + return common.Hash{}, false + } + if h, ok := v.Interface().(common.Hash); ok { + return h, true + } + if v.Kind() != reflect.Array || v.Len() != common.HashLength { + return common.Hash{}, false + } + var h common.Hash + for i := 0; i < common.HashLength; i++ { + h[i] = byte(v.Index(i).Uint()) + } + return h, true +} + +func addressValue(v reflect.Value) (common.Address, bool) { + if !v.IsValid() { + return common.Address{}, false + } + if a, ok := v.Interface().(common.Address); ok { + return a, true + } + if v.Kind() != reflect.Array || v.Len() != common.AddressLength { + return common.Address{}, false + } + var a common.Address + for i := 0; i < common.AddressLength; i++ { + a[i] = byte(v.Index(i).Uint()) + } + return a, true +} + +func bigValue(v reflect.Value) (*big.Int, bool) { + if !v.IsValid() { + return nil, false + } + b, ok := v.Interface().(*big.Int) + if !ok || b == nil { + return nil, false + } + return new(big.Int).Set(b), true +} diff --git a/internal/solvers/redstoneoev/operationdata_test.go b/internal/solvers/redstoneoev/operationdata_test.go new file mode 100644 index 00000000..f6c54558 --- /dev/null +++ b/internal/solvers/redstoneoev/operationdata_test.go @@ -0,0 +1,137 @@ +package redstoneoev + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestEncodeOperationDataRoundTrip(t *testing.T) { + auth := operationAuth{ + AuctionKey: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"), + BidAmount: mustBig("500000000000000"), + MinBundleProfit: mustBig("2200000"), + } + legs := []LiquidationLeg{{ + MarketId: common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5"), + Borrower: common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE"), + MaxSeizeAssets: mustBig("500000000000000000"), + MinProfit: mustBig("625000"), + SwapAmountOut: big.NewInt(760000000), // solver-only estimate; not encoded into operationData + }} + authSig := bytes.Repeat([]byte{0x42}, 65) + + got, err := EncodeOperationData(auth, legs, authSig) + if err != nil { + t.Fatal(err) + } + want := "0x" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "1111111111111111111111111111111111111111111111111111111111111111" + + "0000000000000000000000000000000000000000000000000001c6bf52634000" + + "00000000000000000000000000000000000000000000000000000000002191c0" + + "00000000000000000000000000000000000000000000000000000000000000a0" + + "0000000000000000000000000000000000000000000000000000000000000140" + + "0000000000000000000000000000000000000000000000000000000000000001" + + "6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5" + + "000000000000000000000000629d764ec8563afa701709b52c1a215e865632de" + + "00000000000000000000000000000000000000000000000006f05b59d3b20000" + + "0000000000000000000000000000000000000000000000000000000000098968" + + "0000000000000000000000000000000000000000000000000000000000000041" + + "4242424242424242424242424242424242424242424242424242424242424242" + + "4242424242424242424242424242424242424242424242424242424242424242" + + "4200000000000000000000000000000000000000000000000000000000000000" + if hexutil.Encode(got) != want { + t.Fatalf("operationData ABI mismatch:\n got %s\nwant %s", hexutil.Encode(got), want) + } + back, err := decodeOperationData(got) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + if back.Auth.AuctionKey != auth.AuctionKey || + back.Auth.BidAmount.Cmp(auth.BidAmount) != 0 || + back.Auth.MinBundleProfit.Cmp(auth.MinBundleProfit) != 0 { + t.Fatalf("auth round-trip mismatch: %+v", back.Auth) + } + if len(back.Legs) != 1 { + t.Fatalf("legs len = %d, want 1", len(back.Legs)) + } + if leg := back.Legs[0]; leg.MarketId != legs[0].MarketId || + leg.Borrower != legs[0].Borrower || + leg.MaxSeizeAssets.Cmp(legs[0].MaxSeizeAssets) != 0 || + leg.MinProfit.Cmp(legs[0].MinProfit) != 0 { + t.Fatalf("leg round-trip mismatch: %+v", leg) + } + if !bytes.Equal(back.AuthSig, authSig) { + t.Fatalf("authSig mismatch") + } +} + +func TestEncodeOperationDataRejectsMissingAuth(t *testing.T) { + leg := LiquidationLeg{Borrower: common.Address{19: 1}, MaxSeizeAssets: big.NewInt(1), MinProfit: big.NewInt(1)} + for name, auth := range map[string]operationAuth{ + "no bid": {MinBundleProfit: big.NewInt(1)}, + "no min bundle profit": {BidAmount: big.NewInt(1)}, + "zero min bundle profit": { + BidAmount: big.NewInt(1), + MinBundleProfit: big.NewInt(0), + }, + } { + t.Run(name, func(t *testing.T) { + if _, err := EncodeOperationData(auth, []LiquidationLeg{leg}, nil); err == nil { + t.Fatal("expected invalid auth error") + } + }) + } + if _, err := EncodeOperationData(operationAuth{BidAmount: big.NewInt(1), MinBundleProfit: big.NewInt(1)}, nil, nil); err == nil { + t.Fatal("expected error for empty legs") + } +} + +func TestCallbackAuthDigestBindsLegs(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + auth := operationAuth{ + AuctionKey: common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + BidAmount: big.NewInt(100), + MinBundleProfit: big.NewInt(200), + } + legs := []LiquidationLeg{{ + MarketId: common.Hash{31: 1}, + Borrower: common.Address{19: 2}, + MaxSeizeAssets: big.NewInt(3), + MinProfit: big.NewInt(4), + SwapAmountOut: big.NewInt(999), + }} + digest, err := CallbackAuthDigest(big.NewInt(11155111), common.Address{19: 3}, common.Address{19: 4}, auth, legs) + if err != nil { + t.Fatal(err) + } + sig, err := crypto.Sign(digest.Bytes(), key) + if err != nil { + t.Fatal(err) + } + pub, err := crypto.SigToPub(digest.Bytes(), sig) + if err != nil { + t.Fatal(err) + } + if got, want := crypto.PubkeyToAddress(*pub), crypto.PubkeyToAddress(key.PublicKey); got != want { + t.Fatalf("recovered %s, want %s", got, want) + } + + changed := legs + changed[0].MinProfit.Add(changed[0].MinProfit, big.NewInt(1)) + changedDigest, err := CallbackAuthDigest(big.NewInt(11155111), common.Address{19: 3}, common.Address{19: 4}, auth, changed) + if err != nil { + t.Fatal(err) + } + if changedDigest == digest { + t.Fatal("digest must change when leg minProfit changes") + } +} diff --git a/internal/solvers/redstoneoev/sizing_test.go b/internal/solvers/redstoneoev/sizing_test.go new file mode 100644 index 00000000..bf729291 --- /dev/null +++ b/internal/solvers/redstoneoev/sizing_test.go @@ -0,0 +1,301 @@ +package redstoneoev + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// newQuote builds a USDC(6)/RWA(18) adapter quote with the hot-path scales precomputed, mirroring the +// production buildQuote invariant that LoanScale/CollScale are always non-nil. maxAssets nil ⇒ uncapped. +func newQuote(maxRate string, maxAssets *big.Int) AdapterQuote { + return AdapterQuote{ + MaxRate: mustBig(maxRate), MaxAssets: maxAssets, + LoanScale: chain.Exp10(6), CollScale: chain.Exp10(18), + } +} + +func TestTargetSeizeModes(t *testing.T) { + collateral := mustBig("1000000000000000000") + if got := targetSeize(collateral, true); got.Cmp(collateral) != 0 { + t.Fatalf("full target = %s, want %s", got, collateral) + } + wantPartial := mustBig("900000000000000000") + if got := targetSeize(collateral, false); got.Cmp(wantPartial) != 0 { + t.Fatalf("partial target = %s, want %s", got, wantPartial) + } +} + +// evalLeg sizes a position against the single configured adapter's quote — the only repay→swap→profit +// core (sizeLeg). The adapter argument is ignored (the leg no longer carries an adapter; the contract +// pins the LiquidLane adapter); it is kept so the assertions below read unchanged. +func evalLeg(c Candidate, price *big.Int, _ common.Address, q AdapterQuote, nowTs uint64, sp SizingParams) (LiquidationLeg, *big.Int, bool) { + accrued := morpho.AccruedTotalBorrowAssets(c.Market.State, nowTs) + return sizeLeg(c, price, q, accrued, sp) +} + +// TestEvaluateLegTargetsFullCollateral proves the default sizing path captures full-collateral opportunities, +// including bad-debt-style cases, instead of leaving a configurable bps slice behind. +func TestEvaluateLegTargetsFullCollateral(t *testing.T) { + m := goldenMarket() + cand := Candidate{ + MarketID: common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5"), + Borrower: common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE"), + Market: MarketInfo{State: m}, + Position: goldenBorrower(), + } + price := mustBig("1550000000000000000000000000") // $1550 market price + // Adapter sells the RWA at $1550 minus the curator's 1% minDiscount -> getMaxRate 1534.5e18. + q := newQuote("1534500000000000000000", nil) + sp := SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0} + + leg, profit, ok := evalLeg(cand, price, seedAdapter, q, m.LastUpdate, sp) + if !ok { + t.Fatal("expected a profitable leg at $1550") + } + if leg.MaxSeizeAssets.String() != "1000000000000000000" { // 1 TCOL + t.Fatalf("seized = %s, want 1 TCOL", leg.MaxSeizeAssets) + } + // swapOut at the adapter rate: 1 TCOL × 1534.5 = 1534.5 TLOAN; profit ≈ 49.6 TLOAN. + if out := leg.SwapAmountOut; out.Cmp(big.NewInt(1_533_000_000)) < 0 || out.Cmp(big.NewInt(1_536_000_000)) > 0 { + t.Fatalf("swapOut = %s, want ~1534.5e6", out) + } + if profit.Cmp(big.NewInt(45_000_000)) < 0 || profit.Cmp(big.NewInt(55_000_000)) > 0 { + t.Fatalf("profit = %s, want ~50e6", profit) + } +} + +func TestSizeLegAllowsFullBadDebtSeize(t *testing.T) { + lltv := mustBig("500000000000000000") // 0.5 + price := mustBig("1000000000000000000000000000") // 1000 loan per 1e18 collateral + totalBorrowAssets := mustBig("10000000000") // 10,000 loan tokens at 6 decimals + totalBorrowShares := new(big.Int).Set(totalBorrowAssets) // 1:1 shares↔assets + collateral := mustBig("1000000000000000000") // 1 collateral + debtShares := mustBig("1200000000") // 1,200 loan tokens: debt > full collateral value + state := morpho.MarketState{TotalBorrowAssets: totalBorrowAssets, TotalBorrowShares: totalBorrowShares, + Lltv: lltv, BorrowRatePerSec: big.NewInt(0), Fee: big.NewInt(0), LastUpdate: assignNowTs} + c := Candidate{ + MarketID: assignMarketID, Borrower: common.Address{19: 9}, + Market: MarketInfo{Params: abiMarketParams{LoanToken: tokenA}, State: state}, + Position: morpho.PositionState{BorrowShares: debtShares, Collateral: collateral}, + } + accrued := morpho.AccruedTotalBorrowAssets(state, assignNowTs) + lif := morpho.LiquidationIncentiveFactor(lltv) + if maxSeize := morpho.MaxSeizeForFullDebt(debtShares, price, lif, accrued, totalBorrowShares); maxSeize.Cmp(collateral) <= 0 { + t.Fatalf("fixture must be bad-debt-like: maxSeizeForFullDebt=%s <= collateral=%s", maxSeize, collateral) + } + q := newQuote("1200000000000000000000", nil) // exit at 1200 loan per collateral, above the full-collateral repayment. + full, fullProfit, ok := sizeLeg(c, price, q, accrued, SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0}) + if !ok { + t.Fatal("full bad-debt seize should be profitable") + } + if full.MaxSeizeAssets.Cmp(collateral) != 0 { + t.Fatalf("full bad-debt seize = %s, want all collateral %s", full.MaxSeizeAssets, collateral) + } + partial, partialProfit, ok := sizeLeg(c, price, q, accrued, SizingParams{AllowFullLiquidation: false, SwapHaircutBps: 0}) + if !ok { + t.Fatal("partial fallback should also size") + } + if partial.MaxSeizeAssets.Cmp(mustBig("900000000000000000")) != 0 { + t.Fatalf("partial seize = %s, want fixed 90%%", partial.MaxSeizeAssets) + } + if fullProfit.Cmp(partialProfit) <= 0 { + t.Fatalf("full bad-debt seize should capture more total profit: full=%s partial=%s", fullProfit, partialProfit) + } +} + +// TestEvaluateLegRejectsBadPrice covers the fail-closed guard against a zero/negative settlement price +// (a malformed auction frame) — without it, maxBorrow=0 flags every position liquidatable with phantom +// profit and the bot bids into a guaranteed revert. +func TestEvaluateLegRejectsBadPrice(t *testing.T) { + m := goldenMarket() + cand := Candidate{Market: MarketInfo{State: m}, Position: goldenBorrower()} + q := newQuote("1534500000000000000000", mustBig("100000000000")) + sp := SizingParams{AllowFullLiquidation: true} + for _, bad := range []*big.Int{big.NewInt(0), big.NewInt(-1), nil} { + if _, _, ok := evalLeg(cand, bad, seedAdapter, q, m.LastUpdate, sp); ok { + t.Fatalf("price %v must be rejected", bad) + } + } +} + +func TestEvaluateLegSkipsHealthy(t *testing.T) { + m := goldenMarket() + cand := Candidate{Market: MarketInfo{State: m}, Position: goldenBorrower()} + q := newQuote("1534500000000000000000", nil) + // Healthy at the live $2000 price. + if _, _, ok := evalLeg(cand, mustBig("2000000000000000000000000000"), seedAdapter, q, m.LastUpdate, + SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 200}); ok { + t.Fatal("must not liquidate a healthy position") + } +} + +func TestEvaluateLegSkipsUnprofitableExit(t *testing.T) { + m := goldenMarket() + cand := Candidate{Market: MarketInfo{State: m}, Position: goldenBorrower()} + q := newQuote("1400000000000000000000", nil) + // The position is liquidatable at $1550, but the adapter exit proceeds do not cover repayment. + if _, _, ok := evalLeg(cand, mustBig("1550000000000000000000000000"), seedAdapter, q, m.LastUpdate, + SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0}); ok { + t.Fatal("must skip when adapter output cannot cover repayment") + } +} + +const assignNowTs = 1781243340 + +var assignMarketID = common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5") + +// sizeFixture builds the sizing params + a candidate factory over the seeded golden market (loan token +// tokenA), so the sizing tests can size real legs at a given adapter quote. +func sizeFixture() (SizingParams, func(b byte) Candidate, *big.Int) { + sp := SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0} + info := MarketInfo{Params: abiMarketParams{LoanToken: tokenA}, State: goldenMarket()} + cand := func(b byte) Candidate { + var addr common.Address + addr[19] = b + return Candidate{MarketID: assignMarketID, Borrower: addr, Market: info, Position: goldenBorrower()} + } + return sp, cand, mustBig("1550000000000000000000000000") // price +} + +// TestSizeLegClampsToGetMaxAssets proves the single adapter's getMaxAssets liquidity clamp: when the +// adapter can't absorb the full target seize, the leg is re-sized down so its requested swapAmountOut +// stays within the adapter's getMaxAssets — never a swap that reverts InsufficientAllocate on-chain. +func TestSizeLegClampsToGetMaxAssets(t *testing.T) { + _, cand, price := sizeFixture() + sp := SizingParams{AllowFullLiquidation: false, SwapHaircutBps: 0} + c := cand(1) + accrued := morpho.AccruedTotalBorrowAssets(c.Market.State, assignNowTs) + + // Uncapped first, to learn the full swapOut. + full, fullProfit, ok := sizeLeg(c, price, newQuote("1780000000000000000000", nil), accrued, sp) + if !ok { + t.Fatal("uncapped leg should size") + } + uncapped := full.SwapAmountOut + + // Cap the adapter to 1.5× of... no: cap below the full swapOut so the clamp binds. + budget := new(big.Int).Div(uncapped, big.NewInt(2)) + capped, cappedProfit, ok := sizeLeg(c, price, newQuote("1780000000000000000000", budget), accrued, sp) + if !ok { + t.Fatal("capped leg should still size (smaller)") + } + if capped.SwapAmountOut.Cmp(budget) > 0 { + t.Fatalf("leg over-draws the adapter: swapAmountOut=%s > getMaxAssets=%s", capped.SwapAmountOut, budget) + } + if capped.SwapAmountOut.Cmp(uncapped) >= 0 { + t.Fatalf("a tight budget must trim below the uncapped swapOut: capped=%s uncapped=%s", capped.SwapAmountOut, uncapped) + } + if cappedProfit.Cmp(fullProfit) >= 0 { + t.Fatalf("clamped leg should net less profit: capped=%s full=%s", cappedProfit, fullProfit) + } +} + +// TestCandidatesEmitsSingleSwapLeg proves the strategy emits one LiquidationLeg per liquidatable +// candidate with SwapAmountOut set (one swap per leg, no per-vault Exit split). +func TestCandidatesEmitsSingleSwapLeg(t *testing.T) { + sp, cand, price := sizeFixture() + c := cand(1) + accrued := morpho.AccruedTotalBorrowAssets(c.Market.State, assignNowTs) + leg, _, ok := sizeLeg(c, price, newQuote("1780000000000000000000", mustBig("1000000000000")), accrued, sp) + if !ok { + t.Fatal("position should liquidate") + } + if leg.SwapAmountOut == nil || leg.SwapAmountOut.Sign() <= 0 { + t.Fatalf("leg must carry a positive swapAmountOut, got %v", leg.SwapAmountOut) + } + if leg.MaxSeizeAssets.Sign() <= 0 { + t.Fatalf("leg should seize collateral, got maxSeizeAssets=%s", leg.MaxSeizeAssets) + } +} + +// TestSizeLegClampsSeizeToDebt is the regression for review F2: a barely-liquidatable position with a +// small debt but large collateral. The leg sets MaxSeizeAssets with RepaidShares=0, so Morpho derives the +// repaid shares from the seize and reverts (borrowShares underflow) if the implied repayment exceeds the +// borrower's outstanding debt. Seizing the unclamped 90% target would over-repay; the fix clamps the seize so +// morpho.RepaidAssetsForSeizeAt(MaxSeizeAssets) ≤ the borrower's debt — a full liquidation that never reverts. +func TestSizeLegClampsSeizeToDebt(t *testing.T) { + lltv := mustBig("500000000000000000") // 0.5 — widens the over-repay window vs the golden 0.86 + price := mustBig("1000000000000000000000000000000000000") // 1e36 (collateral≈loan units) + totalBorrowAssets := mustBig("1000000000000000000000000") // 1:1 shares↔assets, no accrual + totalBorrowShares := new(big.Int).Set(totalBorrowAssets) + coll := mustBig("1000000000000000000") // 1e18 collateral + // Debt just above maxBorrow → liquidatable, but worth far less than 90% of the collateral's value. + debtShares := new(big.Int).Add(morpho.MaxBorrow(coll, price, lltv), big.NewInt(1_000_000)) + + state := morpho.MarketState{ + TotalBorrowAssets: totalBorrowAssets, TotalBorrowShares: totalBorrowShares, + Lltv: lltv, BorrowRatePerSec: big.NewInt(0), Fee: big.NewInt(0), LastUpdate: assignNowTs, + } + coll18 := common.HexToAddress("0x00000000000000000000000000000000000000c0") + c := Candidate{ + MarketID: assignMarketID, Borrower: common.Address{19: 1}, + Market: MarketInfo{Params: abiMarketParams{LoanToken: tokenA, CollateralToken: coll18}, State: state}, + Position: morpho.PositionState{BorrowShares: debtShares, Collateral: coll}, + } + accrued := morpho.AccruedTotalBorrowAssets(state, assignNowTs) + debt := morpho.BorrowedAssetsAt(c.Position, accrued, totalBorrowShares) + + // Sanity: the UNCLAMPED 90% target really would over-repay (so the clamp is exercised, not vacuous). + unclampedTarget := morpho.MulDivDown(coll, big.NewInt(9000), big.NewInt(10_000)) + if morpho.RepaidAssetsForSeizeAt(unclampedTarget, price, morpho.LiquidationIncentiveFactor(lltv), accrued, totalBorrowShares).Cmp(debt) <= 0 { + t.Fatal("test fixture is vacuous: the unclamped 90% seize does not over-repay") + } + + sp := SizingParams{AllowFullLiquidation: false, SwapHaircutBps: 0} + // MaxRate sized so the swap proceeds clear the repayment (profitable): swapOut = collIn·rate·1e6/(1e18·1e18). + q := newQuote("2000000000000000000000000000000", mustBig("100000000000000000000000000000000")) + leg, _, ok := sizeLeg(c, price, q, accrued, sp) + if !ok { + t.Fatal("a liquidatable position should size a leg") + } + repaid := morpho.RepaidAssetsForSeizeAt(leg.MaxSeizeAssets, price, morpho.LiquidationIncentiveFactor(lltv), accrued, totalBorrowShares) + if repaid.Cmp(debt) > 0 { + t.Fatalf("seize over-repays: morpho.RepaidAssetsForSeizeAt(%s)=%s > borrowerDebt=%s → Morpho borrowShares underflow", + leg.MaxSeizeAssets, repaid, debt) + } + if leg.MaxSeizeAssets.Cmp(unclampedTarget) >= 0 { + t.Fatalf("seize was not clamped below the 90%% target: seized=%s target=%s", leg.MaxSeizeAssets, unclampedTarget) + } +} + +// TestSizeLegSkipsDustPosition closes the F2 verify gap: maxSeizeForFullDebt = floor(debtAssets·lif·1e36/price) +// floors to 0 for a dust position (tiny debt under a high collateral price). The clamp must then drive target to 0 +// so the leg is SKIPPED (ok=false); the earlier guard that ignored a zero maxSeize left target unclamped and +// over-seized into a borrowShares underflow. Fixture: 2-wei collateral (target=1, past the early guard) at a 2e36 +// price with lltv 0.2 (keeps it liquidatable: MaxBorrow floors to 0) and a 1-share debt → maxSeize floors to 0. +func TestSizeLegSkipsDustPosition(t *testing.T) { + lltv := mustBig("200000000000000000") // 0.2 — keeps the high-priced dust position liquidatable + price := mustBig("2000000000000000000000000000000000000") // 2e36 + totalBorrowAssets := mustBig("1000000000000000000000000") // 1:1, no accrual + totalBorrowShares := new(big.Int).Set(totalBorrowAssets) + coll := big.NewInt(2) // target = morpho.MulDivDown(2, 9000, 10000) = 1 (>0, clears the early target guard) + debtShares := big.NewInt(1) // 1 share → 1 asset; > morpho.MaxBorrow(2, 2e36, 0.2) = 0 ⇒ liquidatable + + state := morpho.MarketState{ + TotalBorrowAssets: totalBorrowAssets, TotalBorrowShares: totalBorrowShares, + Lltv: lltv, BorrowRatePerSec: big.NewInt(0), Fee: big.NewInt(0), LastUpdate: assignNowTs, + } + coll18 := common.HexToAddress("0x00000000000000000000000000000000000000c0") + c := Candidate{ + MarketID: assignMarketID, Borrower: common.Address{19: 3}, + Market: MarketInfo{Params: abiMarketParams{LoanToken: tokenA, CollateralToken: coll18}, State: state}, + Position: morpho.PositionState{BorrowShares: debtShares, Collateral: coll}, + } + accrued := morpho.AccruedTotalBorrowAssets(state, assignNowTs) + if !morpho.IsLiquidatableAt(c.Position, price, lltv, accrued, totalBorrowShares) { + t.Fatal("fixture must be liquidatable so the clamp path is reached") + } + if morpho.MaxSeizeForFullDebt(debtShares, price, morpho.LiquidationIncentiveFactor(lltv), accrued, totalBorrowShares).Sign() != 0 { + t.Fatal("fixture is not a dust case: maxSeizeForFullDebt must floor to 0") + } + sp := SizingParams{AllowFullLiquidation: false, SwapHaircutBps: 0} + q := newQuote("2000000000000000000000000000000", mustBig("100000000000000000000000000000000")) + if _, _, ok := sizeLeg(c, price, q, accrued, sp); ok { + t.Fatal("a dust position whose full-debt seize floors to 0 must be skipped (ok=false), not over-seized") + } +} diff --git a/internal/solvers/redstoneoev/solver_test.go b/internal/solvers/redstoneoev/solver_test.go new file mode 100644 index 00000000..59b97ae7 --- /dev/null +++ b/internal/solvers/redstoneoev/solver_test.go @@ -0,0 +1,1572 @@ +package redstoneoev + +import ( + "context" + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-logr/logr" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/morpho" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +// seedAdapter is the LiquidLane adapter stamped into the seeded market, so tests can assert it flows +// snapshot → leg → operationData. +var seedAdapter = common.HexToAddress("0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b") + +// seededSolver wires a Solver that does no chain/WS I/O: a monitor whose snapshot is pre-populated +// (RedStone source), a stateCache with healthy accounting, and an in-memory signer — exactly the +// surface buildBid reads. nowFn drives accrual/breaker timing deterministically. +func seededSolver(t *testing.T) (*Solver, *testSigner) { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + sgnr := &testSigner{key: key, addr: crypto.PubkeyToAddress(key.PublicKey)} + + id := common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5") + oracle := common.HexToAddress("0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D") + + mon := &apiMonitor{log: logr.Discard()} + mon.snap.Store(&snapshot{ + markets: map[common.Hash]MarketInfo{ + id: {Params: abiMarketParams{Oracle: oracle, Lltv: mustBig("860000000000000000")}, State: goldenMarket()}, + }, + // Cached on-chain oracle price ($1550) — used by testMonitor. + prices: map[common.Hash]*big.Int{id: mustBig("1550000000000000000000000000")}, + quotes: map[common.Hash]AdapterQuote{ + // The single adapter's quote: sells the RWA at ~$1780 (≈1% under the auctioned $1800.9); ample liquidity. + id: newQuote("1780000000000000000000", mustBig("100000000000")), + }, + // Independently-tracked at-risk positions — the SOLE candidate source + // now that the frame's pushed positions are no longer consumed. Both fixture borrowers are seeded so + // workerCandidates surfaces them, evaluated at the frame/onchain price. The captured frame still + // carries these same positions, but they're ignored: candidates come from snap.positions. + positions: map[common.Hash]map[common.Address]morpho.PositionState{ + id: { + // 0x629d… — goldenBorrower (1.0 TCOL, borrowShares 1685600000000000). + common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE"): goldenBorrower(), + // 0x378a… — the frame's second borrower. + common.HexToAddress("0x378a49c640fd9eea888a6a553caae441e2fdebc6"): { + BorrowShares: mustBig("1582399974653062"), Collateral: mustBig("1000000000000000000"), + }, + }, + }, + block: 100, + blockTime: 1781243340, + }) + + cfg := &Config{ + Executor: common.HexToAddress("0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD"), + Callback: common.HexToAddress("0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1"), + Adapter: seedAdapter, + BidWei: mustBig("500000000000000"), // 0.0005 ETH flat bid + MaxTxGasPrice: big.NewInt(1_000_000_000), + Sizing: SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0}, + } + + s := &Solver{ + cfg: cfg, + chainID: big.NewInt(11155111), + nonces: &nonceStore{}, + breaker: newBreaker(3, time.Hour), + seen: newSeenAuctions(maxSeenAuctions), + log: logr.Discard(), + deps: solver.Deps{Signer: sgnr}, + // Disconnected WS client: Send just buffers into its channel, which tests drain to capture solves. + ws: newWSClient(wsConfig{URL: "wss://test", APIKey: "k", Topics: []string{"t"}}, logr.Discard(), func(context.Context, []byte) {}), + } + s.mon = mon + // Healthy accounting: deposit clears MIN_DEPOSIT; callback covers the bid. + s.state.store(cachedState{ + Exec: ExecutorState{Nonce: big.NewInt(7), Deposit: mustBig("100000000000000000"), Locked: false}, + CallbackNative: mustBig("1000000000000000000"), + Rate: mustBig("2500000000"), // 2500e6 loan base units per ETH + GasLimit: redstoneExecutorMaxGasUnits, + Gas: &gasPredictorState{ + FreeAssets: mustBig("100000000000"), + Withdrawable: mustBig("100000000000"), + Acquire: map[common.Address]*big.Int{}, + }, + }) + return s, sgnr +} + +type testFataler interface { + Helper() + Fatalf(format string, args ...any) +} + +func snapshotOf(t testFataler, s *Solver) *snapshot { + t.Helper() + return s.mon.snapshot() +} + +func storeSnapshot(t testFataler, s *Solver, snap *snapshot) { + t.Helper() + switch m := s.mon.(type) { + case *apiMonitor: + m.snap.Store(snap) + case *testMonitor: + m.snap.Store(snap) + default: + t.Fatalf("unexpected monitor type %T", s.mon) + } +} + +func useOnchainTestMonitor(t *testing.T, s *Solver) { + t.Helper() + mon := &testMonitor{log: logr.Discard()} + mon.snap.Store(snapshotOf(t, s)) + s.mon = mon +} + +func setSnapshotBlockTime(t *testing.T, s *Solver, tsMs int64) { + t.Helper() + snap := *snapshotOf(t, s) + snap.blockTime = uint64(tsMs / 1000) + storeSnapshot(t, s, &snap) +} + +// auctionClock returns a clock within ±600s of the captured auction's timestamp, so clampTsAt keeps +// the auction timestamp (deterministic accrual) instead of falling back to wall-clock. +func auctionClock() func() time.Time { return func() time.Time { return time.Unix(1781243340, 0) } } + +// decodeAuction parses the captured live auction frame (the fixture every bid test starts from). +func decodeAuction(t *testing.T) AuctionMessage { + t.Helper() + var a AuctionMessage + if err := json.Unmarshal([]byte(capturedAuction), &a); err != nil { + t.Fatal(err) + } + return a +} + +func recoverCallbackAuthSigner(t *testing.T, s *Solver, op operationData) common.Address { + t.Helper() + legs := make([]LiquidationLeg, len(op.Legs)) + for i, leg := range op.Legs { + legs[i] = LiquidationLeg{ + MarketId: leg.MarketId, + Borrower: leg.Borrower, + MaxSeizeAssets: leg.MaxSeizeAssets, + MinProfit: leg.MinProfit, + } + } + digest, err := CallbackAuthDigest(s.chainID, s.cfg.Callback, s.cfg.Executor, op.Auth, legs) + if err != nil { + t.Fatalf("callback auth digest: %v", err) + } + sig := append([]byte(nil), op.AuthSig...) + if len(sig) != 65 { + t.Fatalf("callback auth signature len = %d, want 65", len(sig)) + } + if sig[64] >= 27 { + sig[64] -= 27 + } + pub, err := crypto.SigToPub(digest.Bytes(), sig) + if err != nil { + t.Fatalf("recover callback auth: %v", err) + } + return crypto.PubkeyToAddress(*pub) +} + +func TestBuildBidHappyPath(t *testing.T) { + s, sgnr := seededSolver(t) + a := decodeAuction(t) + + d := s.buildBid(a, auctionClock()) + if d.skip != "" { + t.Fatalf("expected a bid, got skip %q", d.skip) + } + if d.legs != 2 { + t.Fatalf("legs = %d, want both profitable same-market borrowers", d.legs) + } + if len(d.solve.Data.Borrowers) != 2 { + t.Fatalf("borrowers = %v, want 2", d.solve.Data.Borrowers) + } + if d.solve.Data.Bid != "0.0005" { + t.Fatalf("bid = %q, want 0.0005 (flat BidWei)", d.solve.Data.Bid) + } + if d.solve.Data.Nonce != "8" { // on-chain 7, next is strictly greater + t.Fatalf("nonce = %q, want 8", d.solve.Data.Nonce) + } + // Flat-bid path: gross carries the bundle's Σ loan-token profit (logging only); the bid is the flat BidWei. + if d.gross == nil || d.gross.Sign() <= 0 { + t.Fatalf("gross profit = %v, want > 0", d.gross) + } + + // Full sign path: the LiquidationSig must recover to our signer over the EXECUTOR_V6 digest the + // Executor verifies (keccak(opData) bound into the digest, EIP-191 wrapped). + opData, err := hexutil.Decode(d.solve.Data.OperationData) + if err != nil { + t.Fatal(err) + } + op, err := decodeOperationData(opData) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + if op.Auth.AuctionKey != auctionKeyHash(a) || op.Auth.BidAmount.Cmp(s.cfg.BidWei) != 0 || op.Auth.MinBundleProfit.Sign() <= 0 { + t.Fatalf("bad operation auth: %+v", op.Auth) + } + if len(op.Legs) != 2 || op.Legs[0].MaxSeizeAssets.Sign() <= 0 || op.Legs[0].MinProfit.Sign() <= 0 { + t.Fatalf("encoded leg must carry maxSeizeAssets and minProfit, got %+v", op.Legs) + } + st, _ := s.state.load() + wantBundleFloor := nativeToLoan(new(big.Int).Add(d.gasNative, d.bidNative), st.Rate) + if op.Auth.MinBundleProfit.Cmp(wantBundleFloor) != 0 { + t.Fatalf("minBundleProfit = %s, want %s", op.Auth.MinBundleProfit, wantBundleFloor) + } + for i, leg := range op.Legs { + route := d.gas.Routes[i] + wantLegFloor := nativeToLoan(gasCostNative(gasUnitsForRoute(route), s.cfg.MaxTxGasPrice), st.Rate) + if leg.MinProfit.Cmp(wantLegFloor) != 0 { + t.Fatalf("leg %d minProfit = %s, want %s for route %s", i, leg.MinProfit, wantLegFloor, route) + } + } + if got := recoverCallbackAuthSigner(t, s, op); got != sgnr.addr { + t.Fatalf("callback auth recovered %s, want signer %s", got, sgnr.addr) + } + if got := recoverSolveSigner(t, s, d.solve.Data); got != sgnr.addr { + t.Fatalf("recovered %s, want signer %s", got, sgnr.addr) + } +} + +func TestBuildBidAllowsReplayedSameMarketBundle(t *testing.T) { + s, _ := seededSolver(t) + a := decodeAuction(t) + + d := s.buildBid(a, auctionClock()) + if d.skip != "" { + t.Fatalf("expected a bid, got skip %q", d.skip) + } + opData, err := hexutil.Decode(d.solve.Data.OperationData) + if err != nil { + t.Fatal(err) + } + op, err := decodeOperationData(opData) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + legs := op.Legs + if len(legs) != 2 { + t.Fatalf("encoded %d legs, want two same-market legs after replay", len(legs)) + } + if legs[0].MarketId != legs[1].MarketId { + t.Fatalf("fixture should select two borrowers from one market, got %s and %s", legs[0].MarketId, legs[1].MarketId) + } + state := morpho.AccruedMarketState(goldenMarket(), uint64(a.Timestamp/1000)) + positions := map[common.Address]morpho.PositionState{ + common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE"): goldenBorrower(), + common.HexToAddress("0x378a49c640fd9eea888a6a553caae441e2fdebc6"): { + BorrowShares: mustBig("1582399974653062"), Collateral: mustBig("1000000000000000000"), + }, + } + for _, leg := range legs { + pos, ok := positions[leg.Borrower] + if !ok { + t.Fatalf("unexpected borrower %s", leg.Borrower) + } + replay, ok := morpho.ApplySeizeLiquidation(state, pos, leg.MaxSeizeAssets, mustBig("1550000000000000000000000000")) + if !ok { + t.Fatalf("encoded leg for %s does not replay against current simulated state", leg.Borrower) + } + state = replay.Market + positions[leg.Borrower] = replay.Position + } +} + +func TestBuildBidCapsBundleByCachedGasLimit(t *testing.T) { + s, _ := seededSolver(t) + st, _ := s.state.load() + oneUnknownLeg := fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstUnknownLeg + st.GasLimit = headerGasLimitForUsable(oneUnknownLeg) + s.state.store(st) + + d := s.buildBid(decodeAuction(t), auctionClock()) + if d.skip != "" { + t.Fatalf("expected one gas-fit bid, got skip %q", d.skip) + } + if d.legs != 1 { + t.Fatalf("legs = %d, want only one leg to fit cached gas limit", d.legs) + } + if d.gas.Units > usableBundleGasLimit(st.GasLimit) { + t.Fatalf("predicted gas %d exceeds usable limit %d", d.gas.Units, usableBundleGasLimit(st.GasLimit)) + } +} + +func TestComposeLoanPerEth(t *testing.T) { + cases := []struct { + name string + ethUsd, loanUsd *big.Int + ethFeedDec, loanFeedDec, loanDec int + want string + }{ + {"USDC at 2500, 8-dec feeds, 6-dec loan", mustBig("250000000000"), mustBig("100000000"), 8, 8, 6, "2500000000"}, + {"18-dec loan", mustBig("250000000000"), mustBig("100000000"), 8, 8, 18, "2500000000000000000000"}, + {"mixed feed decimals", mustBig("2500000000000000000000"), mustBig("100000000"), 18, 8, 6, "2500000000"}, + {"zero loan price", mustBig("250000000000"), big.NewInt(0), 8, 8, 6, ""}, + {"negative answer", big.NewInt(-1), mustBig("100000000"), 8, 8, 6, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := composeLoanPerEth(c.ethUsd, c.loanUsd, c.ethFeedDec, c.loanFeedDec, c.loanDec) + if c.want == "" { + if got != nil { + t.Fatalf("want nil, got %s", got) + } + return + } + if got == nil || got.String() != c.want { + t.Fatalf("got %v, want %s", got, c.want) + } + }) + } +} + +func TestRateForUsesCachedOracleAndConversions(t *testing.T) { + s, _ := seededSolver(t) + st, _ := s.state.load() + st.Rate = nil + if got := s.rate(st); got != nil { + t.Fatalf("no cached oracle rate should fail closed, got %v", got) + } + + st.Rate = mustBig("2500000000") + if got := s.rate(st); got == nil || got.String() != "2500000000" { + t.Fatalf("oracle rate present → preferred over config, got %v", got) + } + + if got := loanToNative(mustBig("2500000000"), mustBig("2500000000")); got.Cmp(morpho.Wad) != 0 { + t.Fatalf("2500e6 loan at 2500e6/ETH = %s native units, want 1 ETH", got) + } + if got := loanToNative(mustBig("1"), nil); got.Sign() != 0 { + t.Fatalf("nil rate should convert to 0, got %s", got) + } + if got := nativeToLoan(morpho.Wad, mustBig("2500000000")); got.String() != "2500000000" { + t.Fatalf("1 native at 2500e6/native = %s loan units", got) + } +} + +func selectedBundleForTest(t *testing.T, s *Solver, a AuctionMessage) chosenBundle { + t.Helper() + scored := s.scoredLegs(a, auctionClock()()) + if len(scored) == 0 { + t.Fatal("precondition: expected scored legs") + } + st, _ := s.state.load() + b, skip := s.selectBundleWithGas(scored, st.Gas, st.GasLimit, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("precondition: selectBundle skip %q", skip) + } + return b +} + +func TestBuildBidGasProfitabilityGate(t *testing.T) { + a := decodeAuction(t) + + t.Run("net below min skips gas_unprofitable", func(t *testing.T) { + s, _ := seededSolver(t) + s.cfg.MaxTxGasPrice = mustBig("1000000000000000000") + if d := s.buildBid(a, auctionClock()); d.skip != skipGasUnprofitable { + t.Fatalf("skip = %q, want %q", d.skip, skipGasUnprofitable) + } + }) + + t.Run("exact boundary passes", func(t *testing.T) { + s, _ := seededSolver(t) + b := selectedBundleForTest(t, s, a) + st, _ := s.state.load() + gasUnits := gasPredictionForBundle(b, st.Gas).Units + if b.grossLoan.Cmp(new(big.Int).SetUint64(gasUnits)) <= 0 { + t.Fatalf("test fixture cannot form exact gas boundary: gross=%s gasUnits=%d", b.grossLoan, gasUnits) + } + s.cfg.BidWei = new(big.Int).Sub(b.grossLoan, new(big.Int).SetUint64(gasUnits)) + s.cfg.MaxTxGasPrice = big.NewInt(1) + st.Rate = morpho.Wad + s.state.store(st) + if d := s.buildBid(a, auctionClock()); d.skip != "" { + t.Fatalf("exact after-cost boundary should pass, got skip %q", d.skip) + } + }) + + t.Run("one wei below boundary skips", func(t *testing.T) { + s, _ := seededSolver(t) + b := selectedBundleForTest(t, s, a) + st, _ := s.state.load() + gasUnits := gasPredictionForBundle(b, st.Gas).Units + if b.grossLoan.Cmp(new(big.Int).SetUint64(gasUnits)) <= 0 { + t.Fatalf("test fixture cannot form gas boundary: gross=%s gasUnits=%d", b.grossLoan, gasUnits) + } + s.cfg.BidWei = new(big.Int).Sub(b.grossLoan, new(big.Int).SetUint64(gasUnits)) + s.cfg.BidWei.Add(s.cfg.BidWei, big.NewInt(1)) + s.cfg.MaxTxGasPrice = big.NewInt(1) + st.Rate = morpho.Wad + s.state.store(st) + if d := s.buildBid(a, auctionClock()); d.skip != skipGasUnprofitable { + t.Fatalf("skip = %q, want %q", d.skip, skipGasUnprofitable) + } + }) + + t.Run("dry-run without rate skips because callback auth needs loan profit floors", func(t *testing.T) { + s, _ := seededSolver(t) + s.dryRun = true + st, _ := s.state.load() + st.Rate = nil + s.state.store(st) + if d := s.buildBid(a, auctionClock()); d.skip != skipGasUnprofitable { + t.Fatalf("dry-run no-rate path should skip %q, got %q", skipGasUnprofitable, d.skip) + } + }) +} + +func TestBuildBidSignsConfiguredGasPriceCap(t *testing.T) { + s, _ := seededSolver(t) + s.cfg.MaxTxGasPrice = big.NewInt(1_000_000_000) + + d := s.buildBid(decodeAuction(t), auctionClock()) + if d.skip != "" { + t.Fatalf("expected bid, got skip %q", d.skip) + } + if d.solve.Data.MaxTxGasPrice != s.cfg.MaxTxGasPrice.String() { + t.Fatalf("maxTxGasPrice = %q, want configured cap %s", d.solve.Data.MaxTxGasPrice, s.cfg.MaxTxGasPrice) + } + if got := recoverSolveSigner(t, s, d.solve.Data); got != s.deps.Signer.Address() { + t.Fatalf("recovered %s, want signer %s", got, s.deps.Signer.Address()) + } +} + +func TestFactoryRejectsLiveBiddingWithoutRateSource(t *testing.T) { + t.Setenv("K", "k") + t.Setenv(envDryRun, "false") + + var node yaml.Node + if err := yaml.Unmarshal([]byte(wsline+addrs+api+okBid), &node); err != nil { + t.Fatal(err) + } + _, err := factory(node, solver.Deps{}) + if err == nil { + t.Fatal("expected live factory to reject config without loanEthFeed") + } +} + +// TestBuildBidPriceSource proves the price-source switch through buildBid: the test-only on-chain path sizes +// against the cached on-chain price (ignoring a healthy frame → §6.6 dev-settlement fix), while the +// production auctioned path trusts the frame — a healthy frame skips, and a liquidatable frame drives a +// full SIZED bid (the otherwise-untested mainnet sizing path, since the dev testbed only ever runs the +// on-chain test flag). (Monitor-level marketPrice resolution is covered by TestMarketPriceSource.) +func TestBuildBidPriceSource(t *testing.T) { + const feed = "0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D" + const px5000 = "5000000000000000000000000000" // healthy + const px1550 = "1550000000000000000000000000" // the golden position is liquidatable here + cases := []struct { + name string + onchainTest bool + framePx string + wantSkip string + wantSized bool // for the bidding case, assert a full leg was sized + }{ + {"onchain test flag bids against cached $1550 despite a healthy $5000 frame", true, px5000, "", false}, + {"auctioned trusts the healthy $5000 frame → no_legs", false, px5000, "no_legs", false}, + {"auctioned sizes a full bid at a liquidatable $1550 frame", false, px1550, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := decodeAuction(t) + a.Payload.Prices = map[string]string{feed: tc.framePx} + s, _ := seededSolver(t) + if tc.onchainTest { + useOnchainTestMonitor(t, s) + } + d := s.buildBid(a, auctionClock()) + if d.skip != tc.wantSkip { + t.Fatalf("skip = %q, want %q", d.skip, tc.wantSkip) + } + if tc.wantSized && (d.legs < 1 || len(d.positions) < 1) { + t.Fatalf("expected ≥1 sized leg, got legs=%d positions=%d", d.legs, len(d.positions)) + } + }) + } +} + +// TestBuildBidReservesInFlightFunding checks that a sent bid's payBid native is debited from the cached +// headroom so a second auction in the same window can't double-spend it — and that clearing the +// reservation (as refreshState does after a fresh on-chain read) re-opens the headroom. +func TestBuildBidReservesInFlightFunding(t *testing.T) { + s, _ := seededSolver(t) + a := decodeAuction(t) + // Tighten the callback to exactly one bid's worth of native: a second in-flight bid must be blocked. + st, _ := s.state.load() + st.CallbackNative = new(big.Int).Set(s.cfg.BidWei) + s.state.store(st) + + d1 := s.buildBid(a, auctionClock()) + if d1.skip != "" { + t.Fatalf("first bid should succeed, got skip %q", d1.skip) + } + s.reserve(d1.bidNative, nil, d1.nonce, time.Unix(1781243340, 0), nil, "", gasPrediction{}) + + if d2 := s.buildBid(a, auctionClock()); d2.skip != skipCallbackBalance { + t.Fatalf("second in-flight bid should skip callback_balance (native already committed), got %q", d2.skip) + } + + // A fresh on-chain read whose nonce REACHED d1's (it settled: the Executor sets the nonce to the + // consumed bid's nonce) frees the reservation → headroom re-opens. Uses == d1.nonce, not +1, to pin + // that settlement (on-chain nonce == bid nonce) is what releases it. + s.pruneReservations(d1.nonce, time.Unix(1781243340, 0)) + if d3 := s.buildBid(a, auctionClock()); d3.skip != "" { + t.Fatalf("after the bid resolved a bid should be allowed again, got skip %q", d3.skip) + } +} + +func TestBuildBidChecksDepositGasHeadroom(t *testing.T) { + s, _ := seededSolver(t) + a := decodeAuction(t) + + probe := s.buildBid(a, auctionClock()) + if probe.skip != "" { + t.Fatalf("fixture should bid before tightening deposit, got skip %q", probe.skip) + } + required := new(big.Int).Add(minDeposit, probe.gasNative) + st, _ := s.state.load() + st.Exec.Deposit = new(big.Int).Sub(required, big.NewInt(1)) + s.state.store(st) + + if d := s.buildBid(a, auctionClock()); d.skip != "deposit_low" { + t.Fatalf("deposit below predicted gas headroom should skip deposit_low, got %q", d.skip) + } +} + +func TestBuildBidReservesInFlightGasFunding(t *testing.T) { + s, _ := seededSolver(t) + a := decodeAuction(t) + + probe := s.buildBid(a, auctionClock()) + if probe.skip != "" { + t.Fatalf("fixture should bid before tightening deposit, got skip %q", probe.skip) + } + st, _ := s.state.load() + st.Exec.Deposit = new(big.Int).Add(minDeposit, probe.gasNative) + s.state.store(st) + + d1 := s.buildBid(a, auctionClock()) + if d1.skip != "" { + t.Fatalf("first bid should fit exactly one gas reservation, got skip %q", d1.skip) + } + s.reserve(d1.bidNative, d1.gasNative, d1.nonce, time.Unix(1781243340, 0), nil, "", d1.gas) + + if d2 := s.buildBid(a, auctionClock()); d2.skip != skipDepositLow { + t.Fatalf("second bid should skip deposit_low because gas is already reserved, got %q", d2.skip) + } + + s.pruneReservations(d1.nonce, time.Unix(1781243340, 0)) + if d3 := s.buildBid(a, auctionClock()); d3.skip != "" { + t.Fatalf("after gas reservation clears the bid should fit again, got skip %q", d3.skip) + } +} + +// TestBuildBidSkipsInFlightPosition pins that a second rapid auction for an in-flight position is skipped +// instead of re-bid against the still-stale snapshot. +func TestBuildBidSkipsInFlightPosition(t *testing.T) { + s, _ := seededSolver(t) + onlyBorrower := common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE") + snap := *snapshotOf(t, s) + for market, positions := range snap.positions { + for borrower := range positions { + if borrower != onlyBorrower { + delete(positions, borrower) + } + } + snap.positions[market] = positions + } + storeSnapshot(t, s, &snap) + a := decodeAuction(t) + + d1 := s.buildBid(a, auctionClock()) + if d1.skip != "" || len(d1.positions) == 0 { + t.Fatalf("first bid should succeed with reserved positions, got skip %q positions %d", d1.skip, len(d1.positions)) + } + s.reserve(d1.bidNative, nil, d1.nonce, time.Unix(1781243340, 0), d1.positions, "", gasPrediction{}) + + if d2 := s.buildBid(a, auctionClock()); d2.skip != "in_flight" { + t.Fatalf("a second auction for the same in-flight position(s) must skip in_flight, got %q", d2.skip) + } + + // Once the bid resolves (the on-chain nonce REACHES the bid's nonce — settlement sets it to exactly + // the consumed nonce), the positions free and become biddable again. Uses == d1.nonce, not +1. + s.pruneReservations(d1.nonce, time.Unix(1781243340, 0)) + if d3 := s.buildBid(a, auctionClock()); d3.skip != "" { + t.Fatalf("after the in-flight bid resolved the position should be biddable again, got %q", d3.skip) + } +} + +// TestPruneReservations pins the precise headroom release: a bid whose nonce fell below the on-chain nonce +// (submitted → settled/reverted) or that aged past reservationTTL (lost its auction) is freed, while a +// recent still-pending bid keeps its reservation. (A7). +func TestPruneReservations(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, nil, "", gasPrediction{}) + s.reserve(big.NewInt(200), big.NewInt(20), 10, now, nil, "", gasPrediction{}) + s.reserve(big.NewInt(300), big.NewInt(30), 12, now.Add(-time.Hour), nil, "", gasPrediction{}) + + // nonce 10 frees 8 (below) AND 10 (settlement sets the on-chain nonce to the consumed bid's nonce, so + // nonce == r.nonce must release it — the F1 fix: `<=`, not `<`); 12 is freed by age (> TTL) → none left. + s.pruneReservations(10, now) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 { + t.Fatalf("all reservations should be freed, got bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } + + s.reserve(big.NewInt(500), big.NewInt(50), 11, now, nil, "", gasPrediction{}) + s.pruneReservations(10, now) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.String() != "500" || inFlight.gasNative.String() != "50" { + t.Fatalf("a recent pending bid should be kept, got bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } + + // A bid is freed exactly when the on-chain nonce reaches its nonce (== r.nonce), not only when it passes. + s.pruneReservations(11, now) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 { + t.Fatalf("bid with nonce == on-chain nonce should be freed at settlement, got bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } +} + +func TestWonReservationSurvivesDelayedSettlement(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + pos := []positionKey{{market: common.Hash{1}, borrower: common.Address{2}}} + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, pos, "auction-won", gasPrediction{}) + + s.pruneReservations(7, now.Add(2*time.Minute)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.String() != "100" || len(inFlight.positions) != 1 { + t.Fatalf("won bid must stay reserved while settlement is delayed, bid=%s positions=%d", inFlight.bidNative, len(inFlight.positions)) + } +} + +func TestReservationByAuctionCarriesGasPrediction(t *testing.T) { + s, _ := seededSolver(t) + pred := gasPrediction{Units: 350_000, Routes: []gasRoute{gasRouteAcquire}} + s.reserve(big.NewInt(100), big.NewInt(50), 8, time.Unix(1781243340, 0), nil, "auction-1", pred) + + got, ok := s.reservationByAuction("auction-1") + if !ok { + t.Fatal("reservationByAuction did not find sent bid") + } + if got.gasUnits != pred.Units || got.gasRoutes != "acquire" { + t.Fatalf("attribution = gas %d routes %q", got.gasUnits, got.gasRoutes) + } +} + +func TestAuctionResultReleasesLostBidReservation(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + pos := []positionKey{{market: common.Hash{1}, borrower: common.Address{2}}} + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, pos, "auction-lost", gasPrediction{}) + + s.handleMessage(context.Background(), []byte(`{ + "op":"auction-result", + "id":"auction-lost", + "data":{"bid":"0.0005","liquidator":"0x1111111111111111111111111111111111111111"} + }`)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 || len(inFlight.positions) != 0 { + t.Fatalf("lost auction must release reservation, bid=%s gas=%s inflight=%v", inFlight.bidNative, inFlight.gasNative, inFlight.positions) + } + + s.reserve(big.NewInt(200), big.NewInt(20), 9, now, pos, "auction-won", gasPrediction{}) + s.handleMessage(context.Background(), []byte(`{ + "op":"auction-result", + "id":"auction-won", + "data":{"bid":"0.0005","liquidator":"`+`0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1`+`"} + }`)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.String() != "200" || inFlight.gasNative.String() != "20" { + t.Fatalf("won auction must stay reserved until liquidation result/nonce, bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } +} + +func TestLiquidationResultReleasesOurReservation(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + pos := []positionKey{{market: common.Hash{1}, borrower: common.Address{2}}} + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, pos, "auction-ours", gasPrediction{}) + + s.handleMessage(context.Background(), []byte(`{ + "op":"liquidation-result", + "id":"auction-ours", + "data":{"success":true,"txHash":"","liquidator":"`+`0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1`+`","error":""} + }`)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 || len(inFlight.positions) != 0 { + t.Fatalf("our liquidation result must release reservation, bid=%s gas=%s inflight=%v", inFlight.bidNative, inFlight.gasNative, inFlight.positions) + } + + s.reserve(big.NewInt(200), big.NewInt(20), 9, now, pos, "auction-other", gasPrediction{}) + s.handleMessage(context.Background(), []byte(`{ + "op":"liquidation-result", + "id":"auction-other", + "data":{"success":true,"txHash":"","liquidator":"0x1111111111111111111111111111111111111111","error":""} + }`)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.String() != "200" || inFlight.gasNative.String() != "20" { + t.Fatalf("other solver liquidation result must not release our reservation, bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } +} + +// TestApplyExecutorStateRunsWithoutBalance pins that Executor-state bookkeeping still runs when only the +// callback balance read failed. A transient BalanceAt error must not strand reservations or stale nonces. +func TestApplyExecutorStateRunsWithoutBalance(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + + // A sent bid (nonce 8) pinning headroom, plus a stale local nonce high-water mark (5). + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, []positionKey{{market: common.Hash{1}, borrower: common.Address{2}}}, "", gasPrediction{}) + s.nonces.reconcile(5) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() == 0 || inFlight.gasNative.Sign() == 0 { + t.Fatal("precondition: the reservation should be present") + } + + // On-chain nonce advanced to 9 (the bid settled). Run with bal=nil — the balance-read-failure path. + st := ExecutorState{Nonce: big.NewInt(9), Deposit: mustBig("100000000000000000"), Locked: false} + s.applyExecutorState(st, nil, now) + + // pruneReservations ran: nonce 8 <= 9 → the reservation is freed. + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 { + t.Fatalf("pruneReservations must run despite a failed balance read; bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } + // nonces.reconcile ran: the next nonce is strictly above the on-chain 9. + if got := s.nonces.next(0); got != 10 { + t.Fatalf("nonces.reconcile must run despite a failed balance read; next nonce = %d, want 10", got) + } +} + +// TestFullAuctionLifecycle drives the whole inbound-frame flow through handleMessage: an auction frame +// produces a signed solve on the wire, then tripping the breaker via its REAL input (recorded settlement +// failures, the same path the WS liquidation-result handler feeds) makes buildBid skip "breaker" so a fresh +// auction is dropped (no solve sent). (The WS-frame → recordFailure path is covered by +// TestLiquidationResultFeedsBreaker.) +func TestFullAuctionLifecycle(t *testing.T) { + s, sgnr := seededSolver(t) + useOnchainTestMonitor(t, s) // size against the cached $1550 (the dev settlement price) + + // 1) Auction → a solve is sent on the wire. Stamp the frame as freshly emitted so the too_late gate + // doesn't drop the captured fixture's long-past emit time. + fresh := decodeAuction(t) + fresh.Timestamp = time.Now().UnixMilli() + setSnapshotBlockTime(t, s, fresh.Timestamp) + s.handleMessage(context.Background(), marshal(fresh)) + frame := drainSend(s) + if frame == nil { + t.Fatal("expected a solve to be sent for a liquidatable auction") + } + var solve SolveMessage + if err := json.Unmarshal(frame, &solve); err != nil { + t.Fatal(err) + } + if solve.Op != "solve" || solve.ID != "6382e936-c915-496a-bb3e-fa3b4ccc3a8d" || len(solve.Data.Borrowers) != 2 { + t.Fatalf("bad solve: %+v", solve.Data) + } + // The signature recovers to our signer (full sign path through handleMessage). + if got := recoverSolveSigner(t, s, solve.Data); got != sgnr.addr { + t.Fatalf("solve signature does not recover to signer: got %s", got) + } + + // 2) Trip the breaker through its REAL input — recorded settlement failures (maxFailures=3 within the + // window), the same recordFailure path the WS liquidation-result handler feeds. Record at wall-clock now, + // since the hot path (handleAuction → buildBid) evaluates the breaker with time.Now. After this, tripped. + now := time.Now() + for i := 0; i < 3; i++ { + s.breaker.recordFailure(now) + } + if tripped, _ := s.breaker.tripped(now); !tripped { + t.Fatal("breaker should be tripped after 3 recorded failures within the window") + } + // buildBid (evaluated at the same wall clock as the hot path) must short-circuit to skip "breaker". + if d := s.buildBid(decodeAuction(t), time.Now); d.skip != "breaker" { + t.Fatalf("tripped breaker must skip the bid, got skip %q", d.skip) + } + + // 3) A fresh auction (new id so dedup can't mask it) is dropped by the breaker — nothing sent. + a := decodeAuction(t) + a.ID = "9999aaaa-0000-1111-2222-333344445555" + a.Timestamp = time.Now().UnixMilli() + setSnapshotBlockTime(t, s, a.Timestamp) + s.handleMessage(context.Background(), marshal(a)) + if extra := drainSend(s); extra != nil { + t.Fatalf("breaker tripped — expected no solve, got one: %s", extra) + } +} + +// drainSend returns the next buffered outbound frame, or nil if none is queued. +func drainSend(s *Solver) []byte { + select { + case f := <-s.ws.send: + return f + default: + return nil + } +} + +func TestFeedAuctionDoesNotBuildLiquidationBid(t *testing.T) { + s, _ := seededSolver(t) + raw := []byte(`{ + "op":"auction","id":"feed-auction", + "timestamp":1726058300000,"durationMs":400, + "payload":{"ETH":"250000000000","BTC":"6000000000000","USDC":"99878787"} + }`) + s.handleMessage(context.Background(), raw) + if frame := drainSend(s); frame != nil { + t.Fatalf("feed auction must not produce a liquidation solve: %s", frame) + } +} + +// TestRedstoneClosedPositionNotBid proves we bid off our own tracked on-chain state, not the frame's +// pushed positions: even though the captured frame lists the borrower as deeply underwater, our cached +// position shows it fully closed (zero debt/collateral), so buildBid computes it non-liquidatable and +// does not bid. (The frame's pushed positions are ignored entirely — candidates come from snap.positions.) +func TestRedstoneClosedPositionNotBid(t *testing.T) { + s, _ := seededSolver(t) + id := common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5") + borrower := common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE") + + // Build a FRESH snapshot whose tracked set is a single CLOSED position and store it (the loaded snapshot is + // immutable once stored — mutating its maps would write through the atomic). The frame still lists it as + // liquidatable, but candidates come from snap.positions. + cur := snapshotOf(t, s) + fresh := *cur + fresh.positions = map[common.Hash]map[common.Address]morpho.PositionState{ + id: {borrower: {BorrowShares: big.NewInt(0), Collateral: big.NewInt(0)}}, + } + storeSnapshot(t, s, &fresh) + + if d := s.buildBid(decodeAuction(t), auctionClock()); d.skip != "no_legs" { + t.Fatalf("a closed tracked position is not liquidatable → no_legs, got %q", d.skip) + } +} + +// TestDryRunSuppressesSend pins the OEV_DRY_RUN observe mode: a profitable auction is fully evaluated +// (counted as a would-bid via metrics.bid()) but NO solve is sent on the wire — the operator can watch the +// bot's decisions against a live feed without funding or competing. +func TestDryRunSuppressesSend(t *testing.T) { + s, _ := seededSolver(t) + s.dryRun = true + useOnchainTestMonitor(t, s) // size against the cached $1550 + + // Real metrics on a fresh registry so we can read the would-bid counter back. + reg := prometheus.NewRegistry() + m, err := newMetrics(reg) + if err != nil { + t.Fatalf("newMetrics: %v", err) + } + s.metrics = m + + a := decodeAuction(t) + a.Timestamp = time.Now().UnixMilli() // freshly emitted so the too_late gate doesn't drop it + setSnapshotBlockTime(t, s, a.Timestamp) + s.handleAuction(marshal(a)) + + if f := drainSend(s); f != nil { + t.Fatalf("dry-run must not send a solve, got %s", f) + } + if got := testutil.ToFloat64(m.bids); got != 1 { + t.Fatalf("oev_bids_total = %v, want 1 (dry-run still counts the would-bid)", got) + } +} + +// TestHandleAuctionEmptyIdDedup is the regression for review F8: an empty-id frame must still be deduped on +// a content hash, so a replayed id-less frame can't be processed twice (a second nonce + a double bid). The +// first delivery sends a solve; an identical replay is dropped. +func TestHandleAuctionEmptyIdDedup(t *testing.T) { + s, _ := seededSolver(t) + useOnchainTestMonitor(t, s) // size against the cached $1550 + + a := decodeAuction(t) + a.ID = "" // the frame carries no id + a.Timestamp = time.Now().UnixMilli() // freshly emitted so the too_late gate doesn't drop it + setSnapshotBlockTime(t, s, a.Timestamp) + raw := marshal(a) + + s.handleAuction(raw) + if drainSend(s) == nil { + t.Fatal("first empty-id auction should produce a solve") + } + s.handleAuction(raw) // identical replay + if f := drainSend(s); f != nil { + t.Fatalf("a replayed empty-id frame must be deduped (no second solve), got %s", f) + } +} + +// TestDedupKey pins the F8 key derivation: a present id is authoritative; an empty id derives a stable +// content hash that matches across identical frames and differs when prices differ. +func TestDedupKey(t *testing.T) { + withID := AuctionMessage{ID: "abc"} + if got := withID.dedupKey(); got != "id:abc" { + t.Fatalf("present id must be the key, got %q", got) + } + base := AuctionMessage{Payload: AuctionPayload{ + Prices: map[string]string{"0xoracleA": "100", "0xoracleB": "200"}, + }} + // Same content (prices map order is irrelevant — sorted) → same key. + same := AuctionMessage{Payload: AuctionPayload{ + Prices: map[string]string{"0xoracleB": "200", "0xoracleA": "100"}, + }} + if base.dedupKey() != same.dedupKey() { + t.Fatal("identical empty-id frames must hash to the same key (order-independent)") + } + // A different price → a different key (not falsely deduped). + diff := base + diff.Payload.Prices = map[string]string{"0xoracleA": "101", "0xoracleB": "200"} + if base.dedupKey() == diff.dedupKey() { + t.Fatal("frames with different prices must not share a dedup key") + } + if got := base.dedupKey(); len(got) < 5 || got[:5] != "hash:" { + t.Fatalf("empty-id key must be a content hash, got %q", got) + } +} + +// tokenA is the single loan token used by the bundling tests (the seeded adapter's loan token). +var tokenA = common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") // USDC-like, 6dp + +// scoredFor builds a minimal single-swap scoredLeg with the given borrower nonce and profit (loan units). +func scoredFor(borrowerByte byte, profit *big.Int) scoredLeg { + var b common.Address + b[19] = borrowerByte + return scoredLeg{ + leg: LiquidationLeg{Borrower: b, MarketId: common.Hash{}, SwapAmountOut: profit}, + profit: profit, + } +} + +func headerGasLimitForUsable(usable uint64) uint64 { + return (usable*10_000 + bundleGasLimitSafetyBps - 1) / bundleGasLimitSafetyBps +} + +func TestBundleSearchBounds(t *testing.T) { + t.Run("candidate window keeps only top gross candidates", func(t *testing.T) { + scored := make([]scoredLeg, 0, maxBundleSearchCandidates+100) + for i := maxBundleSearchCandidates + 100; i > 0; i-- { + scored = append(scored, scoredLeg{ + leg: LiquidationLeg{Borrower: common.BigToAddress(big.NewInt(int64(i)))}, + profit: big.NewInt(int64(i)), + }) + } + + got := bundleSearchCandidates(scored) + if len(got) != maxBundleSearchCandidates { + t.Fatalf("candidate window = %d, want %d", len(got), maxBundleSearchCandidates) + } + if got[0].profit.Int64() != int64(maxBundleSearchCandidates+100) || got[len(got)-1].profit.Int64() != 101 { + t.Fatalf("candidate window did not keep top gross range: first=%s last=%s", got[0].profit, got[len(got)-1].profit) + } + }) + + t.Run("depth follows usable gas", func(t *testing.T) { + if got := bundleSearchDepth(1, defaultPriceUpdateFeeds); got != 0 { + t.Fatalf("depth below fixed gas = %d, want 0", got) + } + usable := fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAcquireLeg + gasAdditionalAcquireLeg + if got := bundleSearchDepth(headerGasLimitForUsable(usable), defaultPriceUpdateFeeds); got != 2 { + t.Fatalf("depth = %d, want 2", got) + } + }) +} + +// TestSelectBundleSingleToken exercises the flat-bid selection: every scored leg is already expected-positive +// in sizeLeg, so selectBundle ranks by gross loan profit desc, keeps adding improving gas-fit legs, sums +// grossLoan, and only skips (no_legs) when the scored set is empty. +func TestSelectBundleSingleToken(t *testing.T) { + newSolver := func(cfg *Config) *Solver { + return &Solver{cfg: cfg, log: logr.Discard()} + } + + t.Run("bundles all profitable legs into one bid, grossLoan summed", func(t *testing.T) { + s := newSolver(&Config{}) + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: mustBig("100000000000")}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + scoredFor(1, mustBig("60000000")), + scoredFor(2, mustBig("30000000")), + scoredFor(3, mustBig("9000000")), + }, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 3 || b.grossLoan.String() != "99000000" { // 60+30+9 + t.Fatalf("legs=%d grossLoan=%s, want 3 / 99000000", len(b.legs), b.grossLoan) + } + }) + + t.Run("header gas limit caps the group, keeping the most profitable gas-fit subset", func(t *testing.T) { + s := newSolver(&Config{}) + twoAcquireLegs := fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAcquireLeg + gasAdditionalAcquireLeg + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: mustBig("100000000")}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + scoredFor(1, mustBig("10000000")), + scoredFor(2, mustBig("30000000")), + scoredFor(3, mustBig("20000000")), + }, gasState, headerGasLimitForUsable(twoAcquireLegs), defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 2 || b.grossLoan.String() != "50000000" { // top two: 30 + 20 + t.Fatalf("legs=%d gross=%s, want 2 / 50000000", len(b.legs), b.grossLoan) + } + }) + + t.Run("empty scored set → no_legs", func(t *testing.T) { + s := newSolver(&Config{}) + if _, skip := s.selectBundle(nil); skip != "no_legs" { + t.Fatalf("skip = %q, want no_legs", skip) + } + }) + + t.Run("equal-profit legs ordered deterministically (borrower tie-break)", func(t *testing.T) { + s := newSolver(&Config{}) + // Equal profit + zero marketId, so the deterministic tie-break is the borrower byte (ascending) — + // the same signed bundle regardless of candidate iteration order. + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: mustBig("30000000")}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + scoredFor(3, mustBig("10000000")), + scoredFor(1, mustBig("10000000")), + scoredFor(2, mustBig("10000000")), + }, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 3 || b.legs[0].Borrower[19] != 1 || b.legs[1].Borrower[19] != 2 || b.legs[2].Borrower[19] != 3 { + t.Fatalf("borrower order = %d,%d,%d, want 1,2,3 (deterministic tie-break)", + b.legs[0].Borrower[19], b.legs[1].Borrower[19], b.legs[2].Borrower[19]) + } + }) +} + +// TestSelectBundlePerCollateralBudget pins the shared-liquidity cap: legs seizing the same collateral can't +// jointly over-commit that collateral's getMaxAssets (scoredFor sets SwapAmountOut = profit), so the bundle +// won't revert with InsufficientAllocate on settlement. A leg on a different collateral is unaffected. +func TestSelectBundlePerCollateralBudget(t *testing.T) { + s := &Solver{cfg: &Config{}, log: logr.Discard()} + collA := common.HexToAddress("0x00000000000000000000000000000000000000ca") + collB := common.HexToAddress("0x00000000000000000000000000000000000000cb") + withColl := func(byteID byte, profit int64, c common.Address, maxA int64) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) // SwapAmountOut == profit + sl.collateral = c + sl.maxAssets = big.NewInt(maxA) + return sl + } + // collA budget 100: leg#1 (60) fits; leg#2 (60) would push it to 120>100 → skipped. collB leg#3 fits. + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{collA: big.NewInt(100), collB: big.NewInt(100)}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + withColl(1, 60, collA, 100), + withColl(2, 60, collA, 100), + withColl(3, 10, collB, 100), + }, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + got := map[byte]bool{} + for _, l := range b.legs { + got[l.Borrower[19]] = true + } + if len(b.legs) != 2 || !got[1] || got[2] || !got[3] { + t.Fatalf("included borrowers = %v (legs=%d), want {1,3} — the over-committing same-collateral leg dropped", + got, len(b.legs)) + } + if b.grossLoan.String() != "70" { // 60 (leg#1) + 10 (leg#3); leg#2 excluded + t.Fatalf("grossLoan = %s, want 70", b.grossLoan) + } +} + +func TestSelectBundleAllowsSameMarketStaticLegs(t *testing.T) { + s := &Solver{cfg: &Config{}, log: logr.Discard()} + marketA := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + marketB := common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + withMarket := func(byteID byte, profit int64, market common.Hash) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.leg.MarketId = market + return sl + } + + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: big.NewInt(150)}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + withMarket(1, 60, marketA), + withMarket(2, 50, marketA), + withMarket(3, 40, marketB), + }, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + got := map[byte]bool{} + for _, leg := range b.legs { + got[leg.Borrower[19]] = true + } + if len(b.legs) != 3 || !got[1] || !got[2] || !got[3] { + t.Fatalf("selected borrowers = %v (legs=%d), want both same-market static legs plus other market", got, len(b.legs)) + } +} + +func TestSelectBundleReplaysSameMarketSources(t *testing.T) { + s := &Solver{ + cfg: &Config{ + Sizing: SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0}, + }, + log: logr.Discard(), + } + market := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + coll := common.HexToAddress("0x00000000000000000000000000000000000000c0") + info := MarketInfo{ + Params: abiMarketParams{LoanToken: tokenA, CollateralToken: coll, Lltv: mustBig("500000000000000000")}, + State: morpho.MarketState{ + TotalSupplyAssets: mustBig("5000000000"), + TotalSupplyShares: mustBig("5000000000"), + TotalBorrowAssets: mustBig("3000000000"), + TotalBorrowShares: mustBig("3000000000"), + Lltv: mustBig("500000000000000000"), + Fee: big.NewInt(0), + BorrowRatePerSec: big.NewInt(0), + }, + } + price := mustBig("1000000000000000000000000000") + quote := newQuote("1200000000000000000000", nil) + replayable := func(byteID byte) scoredLeg { + var borrower common.Address + borrower[19] = byteID + pos := morpho.PositionState{BorrowShares: mustBig("1200000000"), Collateral: mustBig("1000000000000000000")} + cand := Candidate{MarketID: market, Borrower: borrower, Market: info, Position: pos} + leg, _, ok := sizeLeg(cand, price, quote, info.State.TotalBorrowAssets, s.cfg.Sizing) + if !ok { + t.Fatal("fixture should size") + } + leg.MaxSeizeAssets = big.NewInt(1) // stale/bogus: selection must ignore and recompute from source + leg.SwapAmountOut = big.NewInt(1) // stale/bogus + return scoredLeg{ + leg: leg, + profit: mustBig("999999999999999999"), + collateral: coll, + source: evalItem{cand: cand, price: price, quote: quote, accrued: info.State.TotalBorrowAssets}, + replay: true, + } + } + + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{coll: mustBig("10000000000000000000000")}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{replayable(1), replayable(2)}, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 2 { + t.Fatalf("selected %d same-market replayed legs, want 2", len(b.legs)) + } + if b.legs[0].MaxSeizeAssets.Cmp(big.NewInt(1)) == 0 || b.legs[0].SwapAmountOut.Cmp(big.NewInt(1)) == 0 { + t.Fatalf("selected stale precomputed leg instead of replaying source: %+v", b.legs[0]) + } + if b.grossLoan.Cmp(mustBig("999999999999999999")) >= 0 { + t.Fatalf("grossLoan used stale bogus profit: %s", b.grossLoan) + } + if _, ok := morpho.ApplySeizeLiquidation(info.State, replayable(1).source.cand.Position, b.legs[0].MaxSeizeAssets, price); !ok { + t.Fatal("first replayed leg should apply to initial market state") + } +} + +func TestSelectNetBundleAvoidsGrossBestGasFalseSkip(t *testing.T) { + collHigh := common.HexToAddress("0x00000000000000000000000000000000000000aa") + collLow := common.HexToAddress("0x00000000000000000000000000000000000000bb") + withColl := func(byteID byte, profit int64, c common.Address) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.collateral = c + return sl + } + s := &Solver{ + cfg: &Config{ + MaxTxGasPrice: big.NewInt(1), + Sizing: SizingParams{}, + }, + log: logr.Discard(), + } + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{collLow: big.NewInt(1_000_000)}, + } + b, skip := s.selectNetBundle([]scoredLeg{ + withColl(1, 640_000, collHigh), // gross-best, but unknown route is net-negative even as a marginal leg + withColl(2, 600_000, collLow), // lower gross, acquire route clears fixed + acquire gas + }, morpho.Wad, gasState, big.NewInt(1), 0, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("lower-gross passing route should be selected, got skip %q", skip) + } + if len(b.legs) != 1 || b.legs[0].Borrower[19] != 2 { + t.Fatalf("selected borrowers = %+v, want only lower-gross acquire leg", b.legs) + } + if got := s.bundleNetNative(b, morpho.Wad, gasState, big.NewInt(1)); got.Cmp(big.NewInt(1)) < 0 { + t.Fatalf("selected bundle net = %s, want >= min margin", got) + } +} + +func TestSelectNetBundleAllowsSameMarketStaticLegs(t *testing.T) { + market := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + collA := common.HexToAddress("0x00000000000000000000000000000000000000a1") + collB := common.HexToAddress("0x00000000000000000000000000000000000000b2") + withMarket := func(byteID byte, profit int64, c common.Address) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.leg.MarketId = market + sl.collateral = c + return sl + } + s := &Solver{ + cfg: &Config{ + MaxTxGasPrice: big.NewInt(1), + Sizing: SizingParams{}, + }, + log: logr.Discard(), + } + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{ + collA: big.NewInt(700_000), + collB: big.NewInt(700_000), + }, + } + b, skip := s.selectNetBundle([]scoredLeg{ + withMarket(1, 700_000, collA), + withMarket(2, 700_000, collB), + }, morpho.Wad, gasState, big.NewInt(1), 0, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 2 { + t.Fatalf("selected %d same-market legs, want 2", len(b.legs)) + } +} + +func TestSelectNetBundleSharesBaseGasAcrossLegs(t *testing.T) { + collA := common.HexToAddress("0x00000000000000000000000000000000000000a1") + collB := common.HexToAddress("0x00000000000000000000000000000000000000b2") + withColl := func(byteID byte, profit int64, c common.Address) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.collateral = c + return sl + } + s := &Solver{ + cfg: &Config{ + MaxTxGasPrice: big.NewInt(1), + Sizing: SizingParams{}, + }, + log: logr.Discard(), + } + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{ + collA: big.NewInt(590_000), + collB: big.NewInt(590_000), + }, + } + b, skip := s.selectNetBundle([]scoredLeg{ + withColl(1, 590_000, collA), // singleton cannot cover fixed + acquire gas + withColl(2, 590_000, collB), // together shares fixed gas and clears the bundle gate + }, morpho.Wad, gasState, big.NewInt(1), 0, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("combined bundle should share base gas and pass, got skip %q", skip) + } + if len(b.legs) != 2 { + t.Fatalf("selected %d legs, want 2", len(b.legs)) + } + if got := s.bundleNetNative(b, morpho.Wad, gasState, big.NewInt(1)); got.Cmp(big.NewInt(1)) < 0 { + t.Fatalf("selected bundle net = %s, want >= min margin", got) + } +} + +func TestSelectNetBundleSearchesPastGreedyBudgetTrap(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000cc") + withColl := func(byteID byte, profit int64) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.collateral = coll + sl.maxAssets = big.NewInt(1_240_000) + return sl + } + s := &Solver{ + cfg: &Config{ + BidWei: big.NewInt(0), + MaxTxGasPrice: big.NewInt(1), + Sizing: SizingParams{}, + }, + log: logr.Discard(), + } + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(1_400_000)}, + } + b, skip := s.selectNetBundle([]scoredLeg{ + withColl(1, 700_000), // gross-best consumes too much shared budget to pair with either 500k leg + withColl(2, 620_000), + withColl(3, 620_000), + }, morpho.Wad, gasState, big.NewInt(1), 0, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("expected lower-gross pair to pass, got skip %q", skip) + } + got := map[byte]bool{} + for _, leg := range b.legs { + got[leg.Borrower[19]] = true + } + if len(b.legs) != 2 || got[1] || !got[2] || !got[3] { + t.Fatalf("selected borrowers = %v (legs=%d), want {2,3}", got, len(b.legs)) + } + if gotNet := s.bundleNetNative(b, morpho.Wad, gasState, big.NewInt(1)); gotNet.Cmp(big.NewInt(1)) < 0 { + t.Fatalf("selected bundle net = %s, want >= min margin", gotNet) + } +} + +func TestSearchBundleDoesNotRequireMonotonicScore(t *testing.T) { + s := &Solver{cfg: &Config{}, log: logr.Discard()} + legs := []scoredLeg{ + scoredFor(1, big.NewInt(1)), + scoredFor(2, big.NewInt(1)), + } + scoreFn := func(b chosenBundle) *big.Int { + if len(b.legs) < 2 { + return big.NewInt(-1) + } + return big.NewInt(10) + } + + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: big.NewInt(2)}, + } + best, ok := s.searchBundle(legs, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds, scoreFn) + if !ok { + t.Fatal("search should keep temporary negative states when a deeper bundle can become profitable") + } + if len(best.bundle.legs) != 2 { + t.Fatalf("selected %d legs, want 2", len(best.bundle.legs)) + } +} + +func TestBundleBidNativeUsesProfitShareFloor(t *testing.T) { + b := chosenBundle{grossLoan: big.NewInt(1_000)} + s := &Solver{ + cfg: &Config{ + BidWei: big.NewInt(100), + TotalBundleProfitBps: 2_000, + }, + log: logr.Discard(), + } + if got := s.bundleBidNative(b, morpho.Wad); got.Cmp(big.NewInt(200)) != 0 { + t.Fatalf("bid = %s, want 20%% of gross native", got) + } + s.cfg.TotalBundleProfitBps = 500 + if got := s.bundleBidNative(b, morpho.Wad); got.Cmp(big.NewInt(100)) != 0 { + t.Fatalf("bid = %s, want minimal bid floor", got) + } +} + +// TestBuildBidStaleEpoch pins the fail-closed epoch gate: a non-empty snapshot must be block-tagged and +// close enough to the auction timestamp that a stuck API cache cannot keep bidding indefinitely. +func TestBuildBidStaleEpoch(t *testing.T) { + a := decodeAuction(t) + now := auctionClock() + s, _ := seededSolver(t) + + fresh := *snapshotOf(t, s) + fresh.block, fresh.blockTime = 0, 0 + storeSnapshot(t, s, &fresh) + if d := s.buildBid(a, now); d.skip != skipStaleEpoch { + t.Fatalf("untagged snapshot must skip %s, got %q", skipStaleEpoch, d.skip) + } + + fresh.block, fresh.blockTime = 123, uint64(a.Timestamp/1000) + storeSnapshot(t, s, &fresh) + if d := s.buildBid(a, now); d.skip != "" { + t.Fatalf("current tagged snapshot should bid, got skip %q", d.skip) + } + + fresh.blockTime = uint64(a.Timestamp/1000) - uint64(snapshotMaxAuctionLag/time.Second) - 1 + storeSnapshot(t, s, &fresh) + if d := s.buildBid(a, now); d.skip != skipStaleEpoch { + t.Fatalf("old tagged snapshot must skip %s, got %q", skipStaleEpoch, d.skip) + } +} + +func TestLegResultCode(t *testing.T) { + code := new(big.Int).Lsh(big.NewInt(0xdeadbeef), 224) + code.Or(code, new(big.Int).Lsh(big.NewInt(42), 16)) + code.Or(code, new(big.Int).Lsh(big.NewInt(3), 8)) + code.Or(code, big.NewInt(7)) + + got := legResultCode(code) + if got.index != 42 || got.status != 3 || got.reason != 7 || got.selector != "0xdeadbeef" { + t.Fatalf("decoded code = (%d,%d,%d,%q), want (42,3,7,0xdeadbeef)", got.index, got.status, got.reason, got.selector) + } +} + +// TestSeenAuctions pins the bounded de-dup: first sight is new, repeats are seen, and the oldest id is +// evicted past cap (so a long-evicted id reads as new again). +func TestSeenAuctions(t *testing.T) { + s := newSeenAuctions(2) + if s.seen("a") { + t.Fatal("first sight of a should be new") + } + if !s.seen("a") { + t.Fatal("repeat of a should be seen") + } + _ = s.seen("b") // [a, b] + if s.seen("c") { // cap 2 → evict a → [b, c] + t.Fatal("c is new") + } + if s.seen("a") { + t.Fatal("a was evicted past cap; should read as new again") + } +} + +// TestLiquidationResultFeedsBreaker pins the WS-driven failure breaker: a liquidation-result frame for OUR +// callback with success:false records exactly one breaker failure (and trips at maxFailures); a success:true +// frame, and a failure for ANOTHER liquidator, record none. This is the sole breaker-failure feed now that +// the on-chain event scan is gone. +func TestLiquidationResultFeedsBreaker(t *testing.T) { + frame := func(liquidator string, success bool) []byte { + return marshal(LiquidationResult{ + Op: "liquidation-result", ID: "a", + Data: LiquidationResultData{Success: success, Liquidator: liquidator, TxHash: "0x1"}, + }) + } + now := time.Now() + + t.Run("success:false for our callback records a failure and trips at maxFailures", func(t *testing.T) { + s, _ := seededSolver(t) // breaker maxFailures = 3 + for i := 0; i < 3; i++ { + s.handleMessage(context.Background(), frame(s.cfg.Callback.Hex(), false)) + } + if tripped, _ := s.breaker.tripped(now); !tripped { + t.Fatal("3 failed liquidation-result frames for our callback must trip the breaker") + } + }) + + t.Run("success:true records none", func(t *testing.T) { + s, _ := seededSolver(t) + for i := 0; i < 5; i++ { + s.handleMessage(context.Background(), frame(s.cfg.Callback.Hex(), true)) + } + if tripped, _ := s.breaker.tripped(now); tripped { + t.Fatal("successful liquidation-result frames must not trip the breaker") + } + }) + + t.Run("a failure for another liquidator records none", func(t *testing.T) { + s, _ := seededSolver(t) + other := common.HexToAddress("0x2222222222222222222222222222222222222222").Hex() + for i := 0; i < 5; i++ { + s.handleMessage(context.Background(), frame(other, false)) + } + if tripped, _ := s.breaker.tripped(now); tripped { + t.Fatal("another solver's failed liquidations must not trip our breaker") + } + }) +} + +// TestTooLate pins that the auction window is measured from the auctioneer emit time. A late-delivered +// frame is dropped; a bogus/future emit timestamp falls back to local elapsed time. +func TestTooLate(t *testing.T) { + now := time.Unix(1781243340, 0) + const timeoutMs = 500 + emit := func(deltaMs int64) int64 { return now.UnixMilli() + deltaMs } + + cases := []struct { + name string + emitMs int64 + start time.Time // local frame-receipt time + wantBad bool + }{ + // Emitted (timeoutMs + slack) ago → past the deadline since emit, even though we just received it. + {"late-delivered frame (emit + slack ago)", emit(-(timeoutMs + 100)), now, true}, + // Emitted exactly at the window edge → not yet too late (strictly greater trips it). + {"emit exactly at the window edge", emit(-timeoutMs), now, false}, + // Fresh frame, emitted just now and just received → in budget. + {"fresh frame", emit(-10), now, false}, + // Emit unset (0): trust the local clock — a slow local path (start long ago) is too late. + {"no emit ts, slow local path", 0, now.Add(-time.Duration(timeoutMs+100) * time.Millisecond), true}, + {"no emit ts, fast local path", 0, now.Add(-10 * time.Millisecond), false}, + // Forward emit timestamp (clock skew / bogus): fall back to the local clock, don't trust emit. + {"future emit ts falls back to local (fast)", emit(5000), now.Add(-10 * time.Millisecond), false}, + {"future emit ts falls back to local (slow)", emit(5000), now.Add(-time.Duration(timeoutMs+100) * time.Millisecond), true}, + } + for _, c := range cases { + if got := tooLate(c.emitMs, timeoutMs, c.start, now); got != c.wantBad { + t.Errorf("%s: tooLate(emit=%d, start=%v) = %v, want %v", c.name, c.emitMs, c.start, got, c.wantBad) + } + } +} + +func TestBuildBidSkips(t *testing.T) { + clock := auctionClock() + healthy := mustBig("100000000000000000000000000000000000000000000") + + stateWith := func(s *Solver, deposit, callback *big.Int, locked bool) cachedState { + st, _ := s.state.load() + st.Exec = ExecutorState{Nonce: big.NewInt(7), Deposit: deposit, Locked: locked} + st.CallbackNative = callback + return st + } + tests := []struct { + name string + mut func(*Solver) + priceOverride *big.Int // if set, re-prices the auction oracle so the position is healthy + want string + }{ + {name: "breaker", mut: func(s *Solver) { s.breaker.blacklist() }, want: "breaker"}, + {name: "signer_locked", mut: func(s *Solver) { + s.state.store(stateWith(s, mustBig("100000000000000000"), mustBig("1000000000000000000"), true)) + }, want: "signer_locked"}, + {name: "deposit_low", mut: func(s *Solver) { + s.state.store(stateWith(s, big.NewInt(1), mustBig("1000000000000000000"), false)) // below MIN_DEPOSIT (1e13) + }, want: "deposit_low"}, + {name: "callback_balance", mut: func(s *Solver) { + s.state.store(stateWith(s, mustBig("100000000000000000"), big.NewInt(1), false)) + }, want: "callback_balance"}, + {name: "no_legs_when_healthy", priceOverride: healthy, want: "no_legs"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + frame := decodeAuction(t) + s, _ := seededSolver(t) + if tc.mut != nil { + tc.mut(s) + } + if tc.priceOverride != nil { + frame.Payload.Prices = map[string]string{"0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D": tc.priceOverride.String()} + } + if d := s.buildBid(frame, clock); d.skip != tc.want { + t.Fatalf("skip = %q, want %q", d.skip, tc.want) + } + }) + } +} diff --git a/internal/solvers/redstoneoev/testhelpers_test.go b/internal/solvers/redstoneoev/testhelpers_test.go new file mode 100644 index 00000000..9cd77b27 --- /dev/null +++ b/internal/solvers/redstoneoev/testhelpers_test.go @@ -0,0 +1,36 @@ +package redstoneoev + +import ( + "math/big" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// mustBig parses a base-10 big.Int, panicking on malformed input — a test-only literal helper. +func mustBig(s string) *big.Int { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + panic("bad big int: " + s) + } + return n +} + +// goldenMarket is the live Sepolia test market state read on-chain (docs/OEV-PLAN.md §6.5/§6.7): +// TLOAN(6dp)/TCOL(18dp), lltv 0.86, IRM borrowRateView = 182418302 wad/sec, lastUpdate 1780059204. +func goldenMarket() morpho.MarketState { + return morpho.MarketState{ + TotalSupplyAssets: big.NewInt(100000000068), + TotalSupplyShares: mustBig("100000000000000000"), + TotalBorrowAssets: big.NewInt(4730000068), + TotalBorrowShares: mustBig("4729999932892591"), + LastUpdate: 1780059204, + Fee: big.NewInt(0), + Lltv: mustBig("860000000000000000"), + BorrowRatePerSec: big.NewInt(182418302), + } +} + +// goldenBorrower is 0x629d… — 1.0 TCOL collateral, borrowShares 1685600000000000. +func goldenBorrower() morpho.PositionState { + return morpho.PositionState{BorrowShares: mustBig("1685600000000000"), Collateral: mustBig("1000000000000000000")} +} diff --git a/internal/solvers/redstoneoev/testsigner_test.go b/internal/solvers/redstoneoev/testsigner_test.go new file mode 100644 index 00000000..37c49aa5 --- /dev/null +++ b/internal/solvers/redstoneoev/testsigner_test.go @@ -0,0 +1,72 @@ +package redstoneoev + +import ( + "crypto/ecdsa" + "math/big" + "testing" + + "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/crypto" + + "github.com/symbioticfi/vault-solver/internal/parse" +) + +// testSigner is a minimal signer.Signer backed by an in-memory key, for tests. SignHash returns the +// 65-byte [R||S||V] form with V in {27,28}, matching the production signer contract. +type testSigner struct { + key *ecdsa.PrivateKey + addr common.Address +} + +func (s *testSigner) Address() common.Address { return s.addr } + +func (s *testSigner) SignHash(hash common.Hash) ([]byte, error) { + sig, err := crypto.Sign(hash.Bytes(), s.key) + if err != nil { + return nil, err + } + if sig[64] < 27 { + sig[64] += 27 + } + return sig, nil +} + +func (s *testSigner) SignTx(tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { + return types.SignTx(tx, types.LatestSignerForChainID(chainID), s.key) +} + +// recoverSolveSigner recovers the EXECUTOR_V6 signer from a solve's signature over its operationData/bid/ +// nonce — the full on-the-wire verification the Executor performs. Shared by the buildBid / lifecycle / WS +// tests, which all assert the recovered address equals the bot's signer. +func recoverSolveSigner(t *testing.T, s *Solver, d SolveData) common.Address { + t.Helper() + opData, err := hexutil.Decode(d.OperationData) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + bid, err := parse.EthToWei(d.Bid, "bid") + if err != nil { + t.Fatalf("parse bid: %v", err) + } + digest, err := ExecutorV6Digest(s.chainID, s.cfg.Callback, crypto.Keccak256Hash(opData), bid, mustBig(d.Nonce), mustBig(d.MaxTxGasPrice)) + if err != nil { + t.Fatalf("digest: %v", err) + } + sig, err := hexutil.Decode(d.LiquidationSig) + if err != nil { + t.Fatalf("decode sig: %v", err) + } + if len(sig) != 65 { + t.Fatalf("sig len = %d, want 65", len(sig)) + } + if sig[64] >= 27 { + sig[64] -= 27 // SigToPub wants V in {0,1} + } + pub, err := crypto.SigToPub(ethSignedMessageHash(digest).Bytes(), sig) + if err != nil { + t.Fatalf("recover: %v", err) + } + return crypto.PubkeyToAddress(*pub) +} diff --git a/internal/solvers/redstoneoev/wsintegration_test.go b/internal/solvers/redstoneoev/wsintegration_test.go new file mode 100644 index 00000000..db82f85c --- /dev/null +++ b/internal/solvers/redstoneoev/wsintegration_test.go @@ -0,0 +1,76 @@ +package redstoneoev + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/gorilla/websocket" +) + +// wsintegration_test.go drives the REAL wsClient (connect → subscribe → read → reconnect-safe) end to +// end against an in-process httptest websocket server, with no chain (the solver reads only its seeded +// snapshot/state). The end-to-end solve + breaker path through handleMessage is covered by +// TestFullAuctionLifecycle (solver_test.go); here we pin the reconnect hygiene that the in-memory path +// can't exercise. + +// TestWSIntegrationDropsStaleSolveAcrossReconnect proves the reconnect hygiene fix (#6): a solve +// buffered while the connection is down is NOT replayed to the next connection (a stale auction has +// closed). One server drops the first connection, then accepts the reconnect and captures any SOLVE the +// client writes (subscribe frames are expected on reconnect and ignored). The URL never changes, so the +// Run goroutine's cfg reads stay race-free. +func TestWSIntegrationDropsStaleSolveAcrossReconnect(t *testing.T) { + s, _ := seededSolver(t) + useOnchainTestMonitor(t, s) + + var conns atomic.Int32 + dropped := make(chan struct{}, 1) + gotSolve := make(chan string, 4) + up := websocket.Upgrader{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + if conns.Add(1) == 1 { // first connection: drop immediately so the client must reconnect + _ = c.Close() + dropped <- struct{}{} + return + } + defer c.Close() //nolint:errcheck // test teardown + for { // reconnect: capture only solve frames (subscribes are expected, ignored) + _, data, rerr := c.ReadMessage() + if rerr != nil { + return + } + if op, _ := opName(data); op == "solve" { + gotSolve <- string(data) + } + } + })) + defer srv.Close() + + s.ws = newWSClient(wsConfig{ + URL: "ws" + strings.TrimPrefix(srv.URL, "http"), APIKey: "k", + Topics: []string{"t"}, BackoffInitial: 10 * time.Millisecond, + }, logr.Discard(), s.handleMessage) + // Pre-load a solve into the send buffer as if a prior auction had queued it during the downtime. + s.ws.Send([]byte(`{"op":"solve","id":"stale","data":{}}`)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = s.ws.Run(ctx) }() + <-dropped // first connection happened and dropped (the buffered solve survived the drop) + + select { + case frame := <-gotSolve: + t.Fatalf("stale solve replayed across reconnect: %s", frame) + case <-time.After(500 * time.Millisecond): + // No solve written on the reconnect — flushSendQueue discarded the stale frame. ✓ + } +} diff --git a/internal/solvers/redstoneoev/wsmessages_test.go b/internal/solvers/redstoneoev/wsmessages_test.go new file mode 100644 index 00000000..83ee4f29 --- /dev/null +++ b/internal/solvers/redstoneoev/wsmessages_test.go @@ -0,0 +1,109 @@ +package redstoneoev + +import ( + "encoding/json" + "testing" +) + +// capturedAuction is a real `oev/liquidations` frame shape (docs/OEV-PLAN.md §6.1): note `timeoutMs` +// (not the docs example's `durationMs`) and the liquidations payload nested under `payload`. +const capturedAuction = `{ + "op":"auction","id":"6382e936-c915-496a-bb3e-fa3b4ccc3a8d","timestamp":1781243340988,"timeoutMs":500, + "payload":{ + "positions":[ + {"market_unique_key":"0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5", + "borrower_address":"0x629d764ec8563afa701709b52c1a215e865632de","current_ltv":108.83, + "oracle_address":"0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D","lltv":"860000000000000000", + "collateral_decimals":18,"loan_decimals":6, + "collateral_address":"0x17e892d4E802B01d7DA49Ca3542560f6851AA4D3", + "loan_address":"0x468BB3245BF520a0CD030BDE029c98aCEAF84C9d", + "collateral_assets":"1000000000000000000","borrow_assets":"1685600048","borrow_shares":"1685600000000000"}, + {"market_unique_key":"0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5", + "borrower_address":"0x378a49c640fd9eea888a6a553caae441e2fdebc6","current_ltv":102.17, + "oracle_address":"0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D","lltv":"860000000000000000", + "collateral_decimals":18,"loan_decimals":6, + "collateral_address":"0x17e892d4E802B01d7DA49Ca3542560f6851AA4D3", + "loan_address":"0x468BB3245BF520a0CD030BDE029c98aCEAF84C9d", + "collateral_assets":"1000000000000000000","borrow_assets":"1582400019","borrow_shares":"1582399974653062"} + ], + "prices":{"0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D":"1800943620100000000000000000"} + } +}` + +func TestDecodeAuctionFrame(t *testing.T) { + if op, err := opName([]byte(capturedAuction)); err != nil || op != "auction" { + t.Fatalf("opName = %q, %v; want auction", op, err) + } + var a AuctionMessage + if err := json.Unmarshal([]byte(capturedAuction), &a); err != nil { + t.Fatal(err) + } + if a.ID != "6382e936-c915-496a-bb3e-fa3b4ccc3a8d" { + t.Fatalf("id = %q", a.ID) + } + if a.TimeoutMs != 500 { + t.Fatalf("timeoutMs = %d, want 500", a.TimeoutMs) + } + if got := a.Payload.Prices["0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D"]; got != "1800943620100000000000000000" { + t.Fatalf("price = %q", got) + } +} + +func TestDetectFeedAuctionFrame(t *testing.T) { + feed := []byte(`{ + "op":"auction","id":"e9803b9f-4318-4dc0-811d-23f2f0b938f2", + "timestamp":1726058300000,"durationMs":400, + "payload":{"ETH":"250000000000","BTC":"6000000000000","USDC":"99878787"} + }`) + if !isFeedAuction(feed) { + t.Fatal("flat feed auction must be detected") + } + if isFeedAuction([]byte(capturedAuction)) { + t.Fatal("liquidation auction must not be detected as a feed auction") + } +} + +// TestDedupKeyTimestamp pins that an id-less frame folds the auctioneer emit timestamp into its dedup key. +// Distinct same-price re-auctions get distinct keys, while reconnect replay of one frame still dedups. +func TestDedupKeyTimestamp(t *testing.T) { + mk := func(ts int64, timeout int) AuctionMessage { + return AuctionMessage{ + Timestamp: ts, TimeoutMs: timeout, + Payload: AuctionPayload{Prices: map[string]string{"0xoracleA": "1800000000000000000000000000"}}, + } + } + a := mk(1781243340988, 500) + replay := mk(1781243340988, 500) // identical frame redelivered on reconnect + later := mk(1781243341488, 500) // same price, emitted 500ms later → a distinct auction + + if a.dedupKey() != replay.dedupKey() { + t.Fatal("identical id-less frames (same timestamp) must dedup to the same key") + } + if a.dedupKey() == later.dedupKey() { + t.Fatal("two same-price id-less frames at different timestamps must NOT collide") + } + // A different timeout (same price + timestamp) is also a distinct frame. + if a.dedupKey() == mk(1781243340988, 400).dedupKey() { + t.Fatal("differing timeoutMs must yield a distinct dedup key") + } +} + +func TestMarshalSolve(t *testing.T) { + msg := SolveMessage{Op: "solve", ID: "abc", Data: SolveData{ + Bid: "0.0005", Nonce: "3", OperationCallback: "0x7Aa3", OperationData: "0x1234", + LiquidationSig: "0xdead", MaxTxGasPrice: "60000000000", Borrowers: []string{"0x629d"}, + }} + var back map[string]any + if err := json.Unmarshal(marshal(msg), &back); err != nil { + t.Fatal(err) + } + if back["op"] != "solve" || back["id"] != "abc" { + t.Fatalf("solve top-level wrong: %v", back) + } + data, _ := back["data"].(map[string]any) + for _, k := range []string{"bid", "nonce", "operationCallback", "operationData", "liquidationSig", "maxTxGasPrice", "borrowers"} { + if _, ok := data[k]; !ok { + t.Fatalf("solve.data missing %q", k) + } + } +} diff --git a/scripts/oev/addresses.sepolia.json b/scripts/oev/addresses.sepolia.json new file mode 100644 index 00000000..554e0637 --- /dev/null +++ b/scripts/oev/addresses.sepolia.json @@ -0,0 +1,25 @@ +{ + "_comment": "RedStone OEV Sepolia testbed addresses - the manifest scripts/oev/oev-balance.sh reads. Single-adapter deploy.", + "chainId": 11155111, + "owner": "0x812492C36b003837C30cB0B63960b86eC9B27309", + "instance": { + "vault": "0xb99F1FeA50f40Bb7C5E568c2De6D79dd0b61EB3A", + "adapter": "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b", + "account": "0xE86974B0B302C389f746AC088E42732A754A941C", + "callback": "0x7EE46765Bd337931E9f2CF6333BeBf2b78D17fcf" + }, + "external": { + "redstoneExecutor": "0xFdFB1862a53a974b166d1f0D012f524Ebd2e0EbD", + "morpho": "0xd011EE229E7459ba1ddd22631eF7bF528d424A14", + "morphoMarket": "0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5", + "morphoOracle": "0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D", + "collateralFeed": "0x6beE2D4dC04afb93b8117849138aA4fCa300c788", + "tloan": "0x468BB3245BF520a0CD030BDE029c98aCEAF84C9d", + "tcol": "0x17e892d4E802B01d7DA49Ca3542560f6851AA4D3", + "testPositions": [ + "0x629d764eC8563AFA701709B52c1a215e865632dE", + "0x378A49C640fD9EeA888A6a553CAae441E2fdebC6", + "0xa42B7e0819DC251445841D1476F30841Fda310E9" + ] + } +} diff --git a/scripts/oev/oev-balance.sh b/scripts/oev/oev-balance.sh new file mode 100755 index 00000000..9a34eeb3 --- /dev/null +++ b/scripts/oev/oev-balance.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# scripts/oev/oev-balance.sh — full "where is the money" balance sheet + rebalance for the RedStone OEV +# Sepolia testbed, so an e2e run is repeatable: see every pool, then restore the ones a liquidation +# drains. Complements scripts/oev/oev-testrun.sh (which drives the bot) and RedStone's harness (positions/feed). +# +# Why this exists: on the testnet the LiquidLane Account is a STUB — it values seized RWA but never +# redeems it. So each liquidation +# • drains the vault's freeAssets (TLOAN fronted to repay Morpho — never replenished by redemption), +# • drains the callback's native ETH (payBid, 0.0005/bid), +# • drains the Executor deposit (gas liability; below the floor the bot self-stops and won't bid), +# • grows the callback's TLOAN (retained profit) and the Account's TCOL (seized, unredeemed). +# `rebalance` recycles the retained profit back into the vault (simulating the missing redemption) and +# tops the ETH pools back up — all signed by the owner key — then defers positions to RedStone's reset. +# +# Reads need only an RPC. Writes need the owner key (OEV_SIGNER_PRIVATE_KEY == manifest `owner`). +# Default action is the read-only sheet; every write is an explicit subcommand. Addresses come from the +# committed manifest (scripts/oev/addresses.sepolia.json) — the single source of truth, no hardcoding. +# +# Usage: +# ETH_RPC_URL_SEPOLIA=https://… scripts/oev/oev-balance.sh [sheet] # read-only balance sheet (default) +# … scripts/oev/oev-balance.sh topup-callback [ETH] # send ETH to the callback (payBid fuel) +# … scripts/oev/oev-balance.sh recycle [TLOAN] # sweep callback profit → vault freeAssets +# … scripts/oev/oev-balance.sh topup-deposit [ETH] # top up the Executor gas deposit (guarded) +# … scripts/oev/oev-balance.sh setup-callback # authorize + fund a new no-preview callback +# … scripts/oev/oev-balance.sh reset # re-arm positions (delegates to RedStone harness) +# … scripts/oev/oev-balance.sh rebalance # recycle + topup-callback (+deposit, +RESET=1 reset) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MANIFEST="${OEV_MANIFEST:-$(dirname "$0")/addresses.sepolia.json}" +CONFIG="${OEV_CONFIG:-$ROOT/config/redstone-oev.sepolia.example.yaml}" +HARNESS="${OEV_HARNESS:-/tmp/symbiotic/symbiotic}" +RPC="${ETH_RPC_URL_SEPOLIA:-${OEV_LIVE_RPC:-${RPC:-}}}" + +# Rebalance targets (override via env). Deposit default is kept above the bot's pre-bid floor. +TARGET_CALLBACK_ETH="${TARGET_CALLBACK_ETH:-0.05}" +TARGET_DEPOSIT_ETH="${TARGET_DEPOSIT_ETH:-0.06}" +KEEP_PROFIT_TLOAN="${KEEP_PROFIT_TLOAN:-0}" # TLOAN to leave in the callback when recycling + +command -v cast >/dev/null || { echo "need foundry 'cast' on PATH (https://getfoundry.sh)" >&2; exit 1; } +command -v jq >/dev/null || { echo "need 'jq' on PATH" >&2; exit 1; } +[ -f "$MANIFEST" ] || { echo "manifest not found: $MANIFEST" >&2; exit 1; } +[ -n "$RPC" ] || { echo "set ETH_RPC_URL_SEPOLIA (Sepolia RPC URL)" >&2; exit 1; } + +m() { jq -r "$1" "$MANIFEST"; } +OWNER=$(m .owner) +EXECUTOR=$(m .external.redstoneExecutor) +MORPHO=$(m .external.morpho) +MARKET=$(m .external.morphoMarket) +ORACLE=$(m .external.morphoOracle) +FEED=$(m .external.collateralFeed) +TLOAN=$(m .external.tloan) +TCOL=$(m .external.tcol) +VAULT=$(m .instance.vault) +ADAPTER=$(m .instance.adapter) +ACCOUNT=$(m .instance.account) +CALLBACK=$(m .instance.callback) +# shellcheck disable=SC2207 # addresses are whitespace-free; word-split into an array (bash 3.2: no mapfile) +POSITIONS=( $(m '.external.testPositions[]') ) + +# Read a numeric scalar (int or decimal) from the bot config — e.g. `ynum bidEth`. Tolerant: a missing key +# yields empty (not a grep exit-1 that would abort the whole sheet under set -e + pipefail). +ynum() { grep -oE "$1:[[:space:]]*\"?[0-9]+(\.[0-9]+)?" "$CONFIG" | grep -oE '[0-9.]+' | tail -1 || true; } + +# --- chain read helpers (tolerant: empty on revert, never abort the sheet) ---------------------- +call() { cast call "$@" --rpc-url "$RPC" 2>/dev/null | awk 'NR==1{print $1}' || true; } +bal() { cast balance "$1" --rpc-url "$RPC" 2>/dev/null | awk '{print $1}' || true; } +# Pipe-processed reads as named functions, so bg() can fan them out like call/bal. +read_feed() { cast call "$FEED" 'latestRoundData()(uint80,int256,uint256,uint256,uint80)' --rpc-url "$RPC" 2>/dev/null | awk 'NR==2{print $1}' || true; } +read_pos() { cast call "$MORPHO" 'position(bytes32,address)(uint256,uint128,uint128)' "$MARKET" "$1" --rpc-url "$RPC" 2>/dev/null | awk '{print $1}' || true; } +# bg — run a read concurrently; its stdout is captured to $RD/ (RD set by sheet). +bg() { local k="$1"; shift; ( "$@" >"$RD/$k" ) & } +# fmt — the one numeric formatter behind the named units (n/a on empty). +fmt() { awk -v w="${1:-}" -v d="$2" -v p="$3" 'BEGIN{ if(w=="")print "n/a"; else printf "%.*f", p, w/d }'; } +eth() { fmt "${1:-}" 1e18 6; } +t6() { fmt "${1:-}" 1e6 4; } +t18() { fmt "${1:-}" 1e18 4; } +usd() { fmt "${1:-}" 1e24 2; } +row() { printf " %-22s %s\n" "$1" "$2"; } + +sheet() { + # config-derived thresholds the warnings compare against (only the sheet needs them). + local MIN_DEPOSIT BID_ETH BID_WEI + # The bot bids when the Executor deposit ≥ MIN_DEPOSIT (solver.go minDeposit=1e13). Gas is debited from + # the deposit post-settlement, independent of the auction, and not pre-reserved — so there is no gas floor. + MIN_DEPOSIT=10000000000000 + BID_ETH=$(ynum bidEth); BID_ETH=${BID_ETH:-0.0005} + BID_WEI=$(cast to-wei "$BID_ETH" ether) + + local RD i eoaEth depWei nonce locked cbEth cbLoan vFree vTotal rate maxAssets acTcol acAssets price feed + RD=$(mktemp -d) + # Fan out the independent reads concurrently — one wave instead of ~15 serial RPC round-trips. + bg eoaEth bal "$OWNER" + bg depWei call "$EXECUTOR" 'deposits(address)(uint256)' "$OWNER" + bg nonce call "$EXECUTOR" 'nonces(address)(uint256)' "$OWNER" + bg locked call "$EXECUTOR" 'locked(address)(bool)' "$OWNER" + bg cbEth bal "$CALLBACK" + bg cbLoan call "$TLOAN" 'balanceOf(address)(uint256)' "$CALLBACK" + bg vFree call "$VAULT" 'freeAssets()(uint256)' + bg vTotal call "$VAULT" 'totalAssets()(uint256)' + bg rate call "$ADAPTER" 'getMaxRate(address)(uint256)' "$TCOL" + bg maxAssets call "$ADAPTER" 'getMaxAssets(address)(uint256)' "$TCOL" + bg acTcol call "$TCOL" 'balanceOf(address)(uint256)' "$ACCOUNT" + bg acAssets call "$ACCOUNT" 'totalAssets()(uint256)' + bg price call "$ORACLE" 'price()(uint256)' + bg feed read_feed + for i in "${!POSITIONS[@]}"; do bg "pos$i" read_pos "${POSITIONS[$i]}"; done + wait + eoaEth=$(cat "$RD/eoaEth"); depWei=$(cat "$RD/depWei"); nonce=$(cat "$RD/nonce"); locked=$(cat "$RD/locked") + cbEth=$(cat "$RD/cbEth"); cbLoan=$(cat "$RD/cbLoan"); vFree=$(cat "$RD/vFree"); vTotal=$(cat "$RD/vTotal") + rate=$(cat "$RD/rate"); maxAssets=$(cat "$RD/maxAssets"); acTcol=$(cat "$RD/acTcol"); acAssets=$(cat "$RD/acAssets") + price=$(cat "$RD/price"); feed=$(cat "$RD/feed") + + echo "════════════════════════ OEV money balance sheet (Sepolia) ════════════════════════" + echo " market price (oracle): \$$(usd "$price") feed: \$$(fmt "${feed:-}" 1e8 2)" + echo "── SIGNER / EXECUTOR ($OWNER)" + row "EOA balance:" "$(eth "$eoaEth") ETH" + row "Executor deposit:" "$(eth "$depWei") ETH (MIN_DEPOSIT $(eth "$MIN_DEPOSIT"))" + row "Executor nonce:" "${nonce:-n/a} locked: ${locked:-n/a}" + echo "── CALLBACK ($CALLBACK)" + row "native (payBid):" "$(eth "$cbEth") ETH (~$(awk -v c="${cbEth:-0}" -v b="$BID_WEI" 'BEGIN{printf "%d", (b>0)?c/b:0}') bids at $BID_ETH ETH)" + row "TLOAN (profit):" "$(t6 "$cbLoan") TLOAN ← recyclable into the vault" + echo "── VAULT ($VAULT)" + row "freeAssets:" "$(t6 "$vFree") TLOAN (deployable liquidity)" + row "totalAssets:" "$(t6 "$vTotal") TLOAN" + echo "── ADAPTER ($ADAPTER)" + row "getMaxRate(TCOL):" "$(t18 "$rate") TLOAN/TCOL (RWA sell price, net discount)" + row "getMaxAssets:" "$(t6 "$maxAssets") TLOAN (per-swap liquidity cap)" + echo "── ACCOUNT ($ACCOUNT) [stub: values, does NOT redeem]" + row "TCOL held (seized):" "$(t18 "$acTcol") TCOL (accumulates unredeemed)" + row "totalAssets (valued):" "$(t6 "$acAssets") TLOAN" + echo "── POSITIONS (market $MARKET)" + local b pos coll bshares + for i in "${!POSITIONS[@]}"; do + b="${POSITIONS[$i]}" + # position() → (supplyShares, borrowShares, collateral), one field per line (read above); take 2nd, 3rd. + # shellcheck disable=SC2207 # whitespace-free fields → array + pos=( $(cat "$RD/pos$i") ) + bshares="${pos[1]:-}"; coll="${pos[2]:-}" + row "${b:0:10}…" "collateral $(t18 "$coll") TCOL borrowShares ${bshares:-n/a}" + done + + # --- warnings: what blocks the next e2e run --- + echo "──────────────────────────────────────────────────────────────────────────────────" + # Exact integer comparisons (all values are sub-ETH/uint128 wei, well within bash's 64-bit ints — no + # awk float rounding). A failed read comes back empty; report THAT distinctly rather than treating it + # as a passing threshold (a flaky read must never print the green "ready" banner). + local warned=0 + if [ -z "$depWei" ]; then + echo " ⚠ could not read Executor deposit (RPC error) — cannot confirm the bot will bid"; warned=1 + elif [ "$depWei" -lt "$MIN_DEPOSIT" ]; then + echo " ⚠ Executor deposit < MIN_DEPOSIT ($(eth "$MIN_DEPOSIT") ETH) — the bot will NOT bid. Fix: topup-deposit"; warned=1 + fi + if [ -z "$cbEth" ]; then + echo " ⚠ could not read callback native balance (RPC error)"; warned=1 + elif [ "$cbEth" -lt "$BID_WEI" ]; then + echo " ⚠ callback native < one bid ($BID_ETH ETH) — payBid would revert. Fix: topup-callback"; warned=1 + fi + if [ -z "$vFree" ]; then + echo " ⚠ could not read vault freeAssets (RPC error)"; warned=1 + elif [ "$vFree" -eq 0 ]; then + echo " ⚠ vault freeAssets = 0 — no liquidity to front a swap. Fix: recycle (or RedStone mint+deposit)"; warned=1 + fi + [ "$warned" = 0 ] && echo " ✓ all pools above their thresholds — ready for an e2e run" + echo "════════════════════════════════════════════════════════════════════════════════════" + rm -rf "$RD" +} + +# --- writes (owner key required) ---------------------------------------------------------------- +# CAVEAT: writes must go through a RELAYING RPC. The public Alchemy Sepolia endpoint accepts txs into a +# private pool without relaying them (they silently never land); point ETH_RPC_URL_SEPOLIA at a public +# relay for writes, e.g. https://ethereum-sepolia-rpc.publicnode.com (docs/OEV-PLAN.md §6.7). +need_key() { + : "${OEV_SIGNER_PRIVATE_KEY:?set OEV_SIGNER_PRIVATE_KEY (the owner key) for write ops}" + local from lc_from lc_owner + from=$(cast wallet address --private-key "$OEV_SIGNER_PRIVATE_KEY") + lc_from=$(printf '%s' "$from" | tr 'A-Z' 'a-z') + lc_owner=$(printf '%s' "$OWNER" | tr 'A-Z' 'a-z') + if [ "$lc_from" != "$lc_owner" ]; then + echo "key address $from != manifest owner $OWNER — refusing to send" >&2; exit 1 + fi + SEND=(cast send --private-key "$OEV_SIGNER_PRIVATE_KEY" --rpc-url "$RPC") +} + +# topup_delta