Files
bank/evmerrors_test.go
zeekayandHanzo Dev 07e4367041 the revert selectors are checked against the contract that emits them
A reverted call arrives as four bytes, and which four decides whether a
customer is told the LTV ceiling refused their borrow or handed an opaque
RPC error. The selectors were hand-copied constants with nothing holding
them to the Solidity, so the test recomputes each one from the signature
it claims to be. All eight are correct — including the two a plausible
guess gets wrong, since BurnLimitExceeded carries (amount, available) and
the error is really named UnauthorizedAccountAccessError.

The string-carrying twins in ErrorMessages.sol hash differently and stay
out on purpose: nothing in the protocol reverts with one.

Alongside it, the four things the chain backend does without an RPC —
reading the deployment, reporting what it carries, judging a destination,
turning an index into an address — are covered offline, so they hold in CI
where there is no node. That includes the reason customer and treasury are
separate branches rather than two numbers on one: an account index that
reached the treasury would hand a customer the bank's own key.

Every guard was checked by breaking what it claims to catch.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-27 07:19:32 -07:00

123 lines
5.2 KiB
Go

package bank
import (
"errors"
"fmt"
"strings"
"testing"
"github.com/luxfi/crypto"
)
// -----------------------------------------------------------------------------
// A revert arrives as four bytes. Which four decides whether the bank reports
// "Undercollateralized" — the LTV ceiling refusing a borrow, which the user is
// entitled to be told — or shrugs and passes an opaque RPC error up. The
// selectors are hand-copied constants, so nothing but this file stops them
// drifting from the contract that emits them.
// -----------------------------------------------------------------------------
// protocolSignatures is the Solidity that produces each selector, transcribed
// from luxfi/liquid: Undercollateralized and UnauthorizedAccountAccessError from
// src/interfaces/ILiquid.sol, the four bare ones from src/base/Errors.sol,
// ERC20CallFailed from src/libraries/TokenUtils.sol.
//
// Two are worth stating explicitly because a plausible guess is wrong for both.
// BurnLimitExceeded takes (amount, available) — the no-argument spelling hashes
// to something else entirely. And the error is named UnauthorizedAccountAccess
// *Error*, so the map's value is the name with that suffix dropped.
//
// Errors.sol also declares string-carrying twins — IllegalArgument(string) and
// friends, from src/base/ErrorMessages.sol — which hash differently and are
// absent here on purpose: no contract in src/ reverts with one. _checkArgument
// and _checkState, which is how Liquid.sol raises every such refusal, throw the
// bare form.
var protocolSignatures = map[string]string{
"0xfddafdf5": "Undercollateralized()",
"0x9a124c80": "IllegalArgument()",
"0x4a613c41": "IllegalState()",
"0x82b42900": "Unauthorized()",
"0x72812ba5": "CannotRepayOnMintBlock()",
"0xd42c86b6": "BurnLimitExceeded(uint256,uint256)",
"0xe7e40b5b": "ERC20CallFailed(address,bool,bytes)",
"0x60ab04f6": "UnauthorizedAccountAccessError()",
}
// TestProtocolSelectorsMatchTheContract recomputes every selector from the
// signature it claims to be, so the table cannot drift from the Solidity
// without this failing. It checks both directions: an entry the bank decodes
// must have a signature written down here, and one written down here must still
// be decoded.
func TestProtocolSelectorsMatchTheContract(t *testing.T) {
for selector, name := range liquidErrors {
sig, known := protocolSignatures[selector]
if !known {
t.Errorf("%s (%s) has no signature recorded — add it, and verify it against luxfi/liquid", selector, name)
continue
}
want := fmt.Sprintf("0x%x", crypto.Keccak256([]byte(sig))[:4])
if selector != want {
t.Errorf("%s is not the selector of %s; keccak256 says %s", selector, sig, want)
}
if !strings.HasPrefix(sig, name) {
t.Errorf("selector %s decodes to %q but its signature is %q", selector, name, sig)
}
}
for selector, sig := range protocolSignatures {
if _, decoded := liquidErrors[selector]; !decoded {
t.Errorf("%s (%s) is no longer decoded — a revert with it would reach the user as an opaque RPC error", selector, sig)
}
}
}
// dataErr is what an RPC client hands back for a reverted call: an error
// carrying the return data of the revert.
type dataErr struct{ data any }
func (dataErr) Error() string { return "execution reverted" }
func (e dataErr) ErrorData() any { return e.data }
func TestRevertReasonNamesTheProtocolRefusal(t *testing.T) {
// The one that matters: a borrow over the LTV ceiling, refused on chain.
// The revert carries only the selector.
if got := revertReason(dataErr{"0xfddafdf5"}); got == nil || got.Error() != "Undercollateralized" {
t.Fatalf("a borrow refused by the LTV ceiling read as %v, not Undercollateralized", got)
}
// A revert with arguments carries them after the selector, and the
// selector is still the first four bytes.
withArgs := "0xd42c86b6" + strings.Repeat("0", 128)
if got := revertReason(dataErr{withArgs}); got == nil || got.Error() != "BurnLimitExceeded" {
t.Errorf("an error carrying arguments read as %v, not BurnLimitExceeded", got)
}
// Nothing says an RPC has to hand back lowercase hex.
if got := revertReason(dataErr{"0xFDDAFDF5"}); got == nil || got.Error() != "Undercollateralized" {
t.Errorf("uppercase hex read as %v, not Undercollateralized", got)
}
}
// TestRevertReasonKeepsWhatItCannotDecode is the other half, and the more
// important one: every shape it does not understand has to arrive unchanged,
// because replacing an error it cannot read with a guess would be worse than
// saying nothing.
func TestRevertReasonKeepsWhatItCannotDecode(t *testing.T) {
if got := revertReason(nil); got != nil {
t.Errorf("no error became %v", got)
}
plain := errors.New("connection refused")
for name, err := range map[string]error{
"an error carrying no revert data": plain,
"revert data that is not hex": dataErr{12345},
"a selector too short to read": dataErr{"0xfdda"},
"a selector nothing declares": dataErr{"0xdeadbeef"},
} {
got := revertReason(err)
if got == nil {
t.Errorf("%s: became nil, so a failed call would read as success", name)
continue
}
if !errors.Is(got, err) {
t.Errorf("%s: was replaced by %q instead of being passed through", name, got)
}
}
}