Skip to content

Add optional upto verify balance preflight and error mapping#40

Merged
ponderingdemocritus merged 1 commit intomainfrom
ponderingdemocritus/upto-verify-balance
Feb 18, 2026
Merged

Add optional upto verify balance preflight and error mapping#40
ponderingdemocritus merged 1 commit intomainfrom
ponderingdemocritus/upto-verify-balance

Conversation

@ponderingdemocritus
Copy link
Contributor

@ponderingdemocritus ponderingdemocritus commented Feb 18, 2026

This PR adds configurable upto verify balance wiring across facilitator config, scheme registration, and the facilitator-server env var UPTO_VERIFY_BALANCE_CHECK. It adds optional post-signature on-chain balanceOf(owner) preflight in upto verify with fail-closed balance_check_failed behavior and early insufficient_balance when underfunded. Settlement now maps transfer revert text to insufficient_balance or insufficient_allowance instead of always returning transaction_failed. Tests were expanded for balance preflight success/failure/order semantics and transfer error mapping, and the README/server docs were updated with configuration and API behavior details.

Summary by CodeRabbit

  • New Features

    • Added optional on-chain balance preflight check during payment verification when enabled
    • Introduced UPTO_VERIFY_BALANCE_CHECK configuration flag to activate balance checks in the Upto payment flow
    • Enhanced settlement error reporting with specific failure reasons (insufficient_balance, balance_check_failed)
  • Documentation

    • Updated documentation with balance preflight feature details and configuration examples

@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

A new optional balance preflight check feature is added to the Upto (batched) payment verification flow via environment variable and config. The enhancement enables on-chain balance validation during payment verification before settlement, complete with new ABI support, error handling, and comprehensive test coverage.

Changes

Cohort / File(s) Summary
Configuration & Environment
examples/facilitator-server/src/index.ts, examples/facilitator-server/src/setup.ts
Added UPTO_VERIFY_BALANCE_CHECK environment variable documentation and propagated the flag through EVM signer configurations to enable balance preflight.
Core Type Definitions
packages/core/src/factory.ts
Added optional upto property to EvmSignerConfig with verifyBalanceCheck field; propagated this option to registerUptoEvmScheme registration path.
Upto EVM Module - Schema & Exports
packages/core/src/upto/evm/constants.ts, packages/core/src/upto/evm/facilitator.ts, packages/core/src/upto/evm/lib.ts, packages/core/src/upto/evm/register.ts
Added balanceOf ABI entry; introduced UptoEvmSchemeOptions interface with optional verifyBalanceCheck; updated UptoEvmScheme constructor to accept and store options; re-exported type and updated registration to pass options through.
Upto EVM Module - Business Logic
packages/core/src/upto/evm/verification.ts, packages/core/src/upto/evm/settlement.ts
Added on-chain balance check in verification flow post-signature validation; introduced mapTransferErrorReason helper in settlement to map transfer failures to specific error reasons (insufficient_balance, insufficient_allowance, transaction_failed).
Test Coverage
packages/core/tests/unit/uptoEvmScheme.test.ts
Added comprehensive test suite for balance preflight including insufficient balance scenarios, RPC failures, short-circuit behavior on invalid signatures/expired auth, and transfer reversion handling.
Documentation
readme.md
Updated to document new upto.verifyBalanceCheck flag, balance preflight behavior, expected error responses, and usage examples in facilitator configuration.

Sequence Diagram

sequenceDiagram
    participant Client
    participant UptoEvmScheme
    participant SignatureVerifier as Signature<br/>Verifier
    participant ERC20Contract as ERC-20<br/>Contract
    participant Settlement as Settlement<br/>Handler

    Client->>UptoEvmScheme: verify(paymentRequest)
    UptoEvmScheme->>SignatureVerifier: Verify permit signature
    alt Signature Invalid
        SignatureVerifier-->>UptoEvmScheme: invalid_permit_signature
        UptoEvmScheme-->>Client: {valid: false, reason}
    else Signature Valid
        SignatureVerifier-->>UptoEvmScheme: signature valid
        alt verifyBalanceCheck enabled
            UptoEvmScheme->>ERC20Contract: readContract(balanceOf, owner)
            alt Balance check passes
                ERC20Contract-->>UptoEvmScheme: balance ≥ amount
                UptoEvmScheme-->>Client: {valid: true}
            else Insufficient balance
                ERC20Contract-->>UptoEvmScheme: balance < amount
                UptoEvmScheme-->>Client: {valid: false, reason: insufficient_balance}
            else RPC failure
                ERC20Contract--xUptoEvmScheme: error
                UptoEvmScheme-->>Client: {valid: false, reason: balance_check_failed}
            end
        else verifyBalanceCheck disabled
            UptoEvmScheme-->>Client: {valid: true}
        end
    end
    
    Client->>Settlement: settle(paymentRequest)
    Settlement->>Settlement: Execute transferFrom
    alt Transfer succeeds
        Settlement-->>Client: {success: true}
    else Transfer fails
        Settlement->>Settlement: mapTransferErrorReason(error)
        alt Low balance
            Settlement-->>Client: {success: false, reason: insufficient_balance}
        else Low allowance
            Settlement-->>Client: {success: false, reason: insufficient_allowance}
        else Other error
            Settlement-->>Client: {success: false, reason: transaction_failed}
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • upto cleanup #5: "upto cleanup" — Refactored the same upto EVM facilitator/verification/settlement stack that this PR extends with new balance preflight logic and configuration threading.

Poem

🐰 Through verify gates, a balance check hops,
No more surprised when the transfer just stops!
Pre-flight the funds with a contract's sweet call,
The Upto flow's smarter—it won't fail at all! 🌾

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding optional balance verification preflight and improved error mapping for the upto flow.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ponderingdemocritus/upto-verify-balance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/core/src/upto/evm/verification.ts (1)

241-247: Consider logging the RPC error before returning balance_check_failed.

The catch block silently discards the error. For operators debugging why verifications are failing, it would help to log the underlying RPC error (similar to how settlement.ts line 191 logs the error before returning).

Proposed change
-    } catch {
+    } catch (error) {
+      console.error("Balance preflight failed:", errorSummary(error));
       return {
         isValid: false,
         invalidReason: "balance_check_failed",
         payer,
       };
     }

This would require importing errorSummary from ./constants.js (already partially imported on line 15).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/upto/evm/verification.ts` around lines 241 - 247, The catch
in the balance check in verifyPayer (or the surrounding verification function in
packages/core/src/upto/evm/verification.ts) silently discards the RPC error;
import errorSummary from ./constants.js (it's already partially imported) and
call processLogger.error (or the same logger used in this file) with a concise
message and errorSummary(err) inside that catch before returning the { isValid:
false, invalidReason: "balance_check_failed", payer } object so operators can
see the underlying RPC failure.
packages/core/src/factory.ts (1)

53-62: Consider using UptoEvmSchemeOptions type instead of inlining the shape.

The inline type { verifyBalanceCheck?: boolean } is structurally identical to UptoEvmSchemeOptions today, but if UptoEvmSchemeOptions gains additional fields in the future, this inline definition would silently drift out of sync, requiring manual updates in two places.

Proposed change
+import type { UptoEvmSchemeOptions } from "./upto/evm/facilitator.js";
 
 export interface EvmSignerConfig {
   // ...
-  upto?: {
-    verifyBalanceCheck?: boolean;
-  };
+  upto?: UptoEvmSchemeOptions;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/factory.ts` around lines 53 - 62, The upto property in the
exported options is currently typed inline as `{ verifyBalanceCheck?: boolean
}`, which duplicates the shape of UptoEvmSchemeOptions; replace the inline type
with the shared UptoEvmSchemeOptions type (use upto?: UptoEvmSchemeOptions) so
the property stays in sync if that type evolves; update any imports/exports in
this file to reference UptoEvmSchemeOptions and ensure type usage in functions
like the factory/constructor that read upto still type-checks after the change.
packages/core/tests/unit/uptoEvmScheme.test.ts (1)

395-472: Consider adding a test that confirms readContract is never called when verifyBalanceCheck is absent (default).

The four new tests confirm preflight behavior when the flag is true and verify short-circuit ordering. However, there is no test that proves the feature is strictly opt-in — i.e., that the default new UptoEvmScheme(mockSigner) path never invokes readContract during verify. Without that test, a regression that calls readContract unconditionally would go undetected.

💡 Suggested additional test
it("does not run balance preflight when verifyBalanceCheck is not set", async () => {
  const readContractMock = mock(() => Promise.resolve(1000000n));
  mockSigner = createMockSigner({ readContract: readContractMock });
  // Default scheme — no options
  scheme = new UptoEvmScheme(mockSigner);

  const payload = createValidPayload();
  const requirements = createValidRequirements();

  const result = await scheme.verify(payload, requirements);

  expect(result.isValid).toBe(true);
  expect(readContractMock).not.toHaveBeenCalled();
});

Would you like me to open an issue to track adding this coverage?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/tests/unit/uptoEvmScheme.test.ts` around lines 395 - 472, Add a
unit test to assert that balance preflight is opt-in: instantiate UptoEvmScheme
with no options (i.e., omit verifyBalanceCheck), supply a mock signer whose
readContract is a jest/mock function (e.g., readContractMock created via
createMockSigner), call scheme.verify(payload, requirements) using
createValidPayload/createValidRequirements, assert result.isValid is true and
that readContractMock was not called; reference UptoEvmScheme,
verifyBalanceCheck, verify, readContract, createMockSigner, createValidPayload,
and createValidRequirements to locate where to add this test.
examples/facilitator-server/src/setup.ts (1)

112-112: upto object is always emitted even when verifyBalanceCheck is false.

When UPTO_VERIFY_BALANCE_CHECK is false, passing upto: { verifyBalanceCheck: false } is equivalent to omitting the key (assuming the core treats undefined and false identically). Consider conditionally spreading it to make the opt-in nature explicit at the call site.

♻️ Conditional spread
-       upto: { verifyBalanceCheck: UPTO_VERIFY_BALANCE_CHECK },
+       ...(UPTO_VERIFY_BALANCE_CHECK ? { upto: { verifyBalanceCheck: true } } : {}),

Apply the same change on line 156.

Also applies to: 156-156

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/facilitator-server/src/setup.ts` at line 112, The current call
always emits an upto object with verifyBalanceCheck set to
UPTO_VERIFY_BALANCE_CHECK even when false; update the callsite so the upto key
is only included when UPTO_VERIFY_BALANCE_CHECK is truthy by conditionally
spreading the property (e.g. use ...(UPTO_VERIFY_BALANCE_CHECK ? { upto: {
verifyBalanceCheck: UPTO_VERIFY_BALANCE_CHECK } } : {}) or equivalent) instead
of always passing upto: { verifyBalanceCheck: UPTO_VERIFY_BALANCE_CHECK }, and
apply the same conditional-spread change at the other occurrence referenced
around line 156; keep references to UPTO_VERIFY_BALANCE_CHECK and the upto
object to locate the spots (same change at both call sites).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/tests/unit/uptoEvmScheme.test.ts`:
- Around line 706-729: The test failure stems from mapTransferErrorReason in
settlement.ts only matching two string patterns, so extend its matching logic to
also inspect decoded ABI error names and additional variants: check error.name
(or decodedError.name) for "ERC20InsufficientBalance" (and other ERC20-style
error names), perform case-insensitive contains checks for both "transfer amount
exceeds balance" and "insufficient balance" without relying on an "ERC20:"
prefix, and as a fallback detect token-related insufficiency by searching for
both "insufficient" and "balance" tokens in the error message; update any tests
referencing mapTransferErrorReason or UptoEvmScheme.settle expectations
accordingly.

---

Nitpick comments:
In `@examples/facilitator-server/src/setup.ts`:
- Line 112: The current call always emits an upto object with verifyBalanceCheck
set to UPTO_VERIFY_BALANCE_CHECK even when false; update the callsite so the
upto key is only included when UPTO_VERIFY_BALANCE_CHECK is truthy by
conditionally spreading the property (e.g. use ...(UPTO_VERIFY_BALANCE_CHECK ? {
upto: { verifyBalanceCheck: UPTO_VERIFY_BALANCE_CHECK } } : {}) or equivalent)
instead of always passing upto: { verifyBalanceCheck: UPTO_VERIFY_BALANCE_CHECK
}, and apply the same conditional-spread change at the other occurrence
referenced around line 156; keep references to UPTO_VERIFY_BALANCE_CHECK and the
upto object to locate the spots (same change at both call sites).

In `@packages/core/src/factory.ts`:
- Around line 53-62: The upto property in the exported options is currently
typed inline as `{ verifyBalanceCheck?: boolean }`, which duplicates the shape
of UptoEvmSchemeOptions; replace the inline type with the shared
UptoEvmSchemeOptions type (use upto?: UptoEvmSchemeOptions) so the property
stays in sync if that type evolves; update any imports/exports in this file to
reference UptoEvmSchemeOptions and ensure type usage in functions like the
factory/constructor that read upto still type-checks after the change.

In `@packages/core/src/upto/evm/verification.ts`:
- Around line 241-247: The catch in the balance check in verifyPayer (or the
surrounding verification function in packages/core/src/upto/evm/verification.ts)
silently discards the RPC error; import errorSummary from ./constants.js (it's
already partially imported) and call processLogger.error (or the same logger
used in this file) with a concise message and errorSummary(err) inside that
catch before returning the { isValid: false, invalidReason:
"balance_check_failed", payer } object so operators can see the underlying RPC
failure.

In `@packages/core/tests/unit/uptoEvmScheme.test.ts`:
- Around line 395-472: Add a unit test to assert that balance preflight is
opt-in: instantiate UptoEvmScheme with no options (i.e., omit
verifyBalanceCheck), supply a mock signer whose readContract is a jest/mock
function (e.g., readContractMock created via createMockSigner), call
scheme.verify(payload, requirements) using
createValidPayload/createValidRequirements, assert result.isValid is true and
that readContractMock was not called; reference UptoEvmScheme,
verifyBalanceCheck, verify, readContract, createMockSigner, createValidPayload,
and createValidRequirements to locate where to add this test.

Comment on lines +706 to +729
it("returns insufficient_balance when transferFrom reverts for low balance", async () => {
const writeContractMock = mock()
.mockImplementationOnce(() =>
Promise.resolve("0xpermittx" as `0x${string}`)
)
.mockImplementationOnce(() =>
Promise.reject(
new Error("ERC20: transfer amount exceeds balance")
)
);

mockSigner = createMockSigner({
writeContract: writeContractMock,
});
scheme = new UptoEvmScheme(mockSigner);

const payload = createValidPayload();
const requirements = createValidRequirements();

const result = await scheme.settle(payload, requirements);

expect(result.success).toBe(false);
expect(result.errorReason).toBe("insufficient_balance");
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "transfer amount exceeds balance|insufficient.balance|ERC20Insufficient|errorReason" \
  --type ts --type tsx -C 3

Repository: daydreamsai/facilitator

Length of output: 94


🏁 Script executed:

#!/bin/bash
# First locate settlement.ts file
fd -t f "settlement" --type f

# Then search for error matching patterns
rg -n "transfer amount exceeds balance|insufficient.balance|ERC20Insufficient|errorReason" \
  --type ts -C 3

Repository: daydreamsai/facilitator

Length of output: 28216


🏁 Script executed:

# Find the errorSummary function definition
rg -n "errorSummary" --type ts -A 5 -B 2

Repository: daydreamsai/facilitator

Length of output: 7853


🏁 Script executed:

cat -n packages/core/src/upto/evm/constants.ts | sed -n '95,130p'

Repository: daydreamsai/facilitator

Length of output: 806


🏁 Script executed:

# Check if codebase mentions viem's custom error handling or OZ v5
rg -n "custom.error|ERC20InsufficientBalance|viem.*error|decodeError" --type ts -A 2

Repository: daydreamsai/facilitator

Length of output: 403


🏁 Script executed:

# Search for any OZ v5 references, custom error handling, or viem imports
rg -n "OpenZeppelin|v5|custom.?error|viem" --type ts packages/core/src --max-count 20

Repository: daydreamsai/facilitator

Length of output: 1440


🏁 Script executed:

# Check how writeContract is being used and what error handling exists
rg -n "writeContract|ContractError" --type ts packages/core/tests/unit/uptoEvmScheme.test.ts -B 2 -A 3

Repository: daydreamsai/facilitator

Length of output: 4498


🏁 Script executed:

# Check what erc20Abi contains - look for its definition in constants.ts
rg -n "erc20Abi" --type ts packages/core/src/upto/evm/constants.ts -A 50 | head -80

Repository: daydreamsai/facilitator

Length of output: 1472


🏁 Script executed:

# Check if there are any error definitions in the erc20Abi or any custom error handling
rg -n "error.*type|CustomError|ERC20" --type ts packages/core/src/upto/evm -A 2

Repository: daydreamsai/facilitator

Length of output: 314


🏁 Script executed:

# Look for any test cases that show what actual error messages look like
rg -n "shortMessage|message.*ERC20\|ERC20.*message" --type ts packages/core/tests -B 2 -A 2

Repository: daydreamsai/facilitator

Length of output: 1607


String-based error matching may miss OpenZeppelin v5+ and other ERC-20 variants.

The test covers the OZ v4 revert string "ERC20: transfer amount exceeds balance". The settlement code uses string pattern matching via mapTransferErrorReason(), which checks only two patterns: "transfer amount exceeds balance" and "insufficient balance" (lines 25–31 in settlement.ts). OpenZeppelin v5 emits ERC20InsufficientBalance as an ABI-encoded custom error, which viem may decode into a different message format that would not match these patterns, causing it to surface as transaction_failed rather than insufficient_balance.

Expand the error mapping to catch:

  • ABI-decoded custom error names if viem provides them (e.g., "ERC20InsufficientBalance")
  • Common variants without the "ERC20:" prefix (e.g., "transfer amount exceeds balance")
  • Any other ERC-20 implementations that use different message formats
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/tests/unit/uptoEvmScheme.test.ts` around lines 706 - 729, The
test failure stems from mapTransferErrorReason in settlement.ts only matching
two string patterns, so extend its matching logic to also inspect decoded ABI
error names and additional variants: check error.name (or decodedError.name) for
"ERC20InsufficientBalance" (and other ERC20-style error names), perform
case-insensitive contains checks for both "transfer amount exceeds balance" and
"insufficient balance" without relying on an "ERC20:" prefix, and as a fallback
detect token-related insufficiency by searching for both "insufficient" and
"balance" tokens in the error message; update any tests referencing
mapTransferErrorReason or UptoEvmScheme.settle expectations accordingly.

@ponderingdemocritus ponderingdemocritus merged commit 70b6667 into main Feb 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant