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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions oor/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,35 @@ func (e *ErrOutputPolicyViolation) Is(target error) bool {
return ok
}

// ErrUserBalanceExceeded is the client-facing typed error returned when
// the operator rejects an OOR submit because a recipient mailbox's
// aggregate VTXO balance would exceed the operator's MaxUserBalance cap.
//
// Unlike ErrOutputPolicyViolation, this is NOT terminal for the same
// output shape: the rejection clears once the recipient spends or
// refreshes its balance down, so a custodial sender (e.g. a swap server
// holding the value on the recipient's behalf) should retain the value
// and retry later rather than restructuring the outputs. The wrapped
// Reason carries the operator's human-readable explanation and is
// suitable for surfacing directly in UX.
type ErrUserBalanceExceeded struct {
Reason string
}

// Error returns a human-readable description of the rejection cause.
func (e *ErrUserBalanceExceeded) Error() string {
return fmt.Sprintf("oor user balance exceeded: %s", e.Reason)
}

// Is reports whether target is also an *ErrUserBalanceExceeded,
// supporting the standard errors.Is comparison without forcing callers
// to compare reasons.
func (e *ErrUserBalanceExceeded) Is(target error) bool {
_, ok := target.(*ErrUserBalanceExceeded)

return ok
}

// ErrInvalidAncestry is the typed error returned by the receive-side
// ancestry cross-check when an operator-supplied IncomingVTXOMetadata
// fails one of the structural invariants required to bind the produced
Expand Down Expand Up @@ -118,6 +147,11 @@ func ClassifySubmitError(err error) error {
Reason: rejected.Reason,
}

case oorpb.OORRejectCode_OOR_REJECT_USER_BALANCE:
return &ErrUserBalanceExceeded{
Reason: rejected.Reason,
}
Comment on lines +150 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Carry the balance reject code to callers

When arkd returns OOR_REJECT_USER_BALANCE on the async SubmitPackage response, the only production consumer I found (darepod/server.go's registerOOREventRoutes) calls this classifier and immediately flattens the result to classified.Error() in OutboxErrorEvent.ErrorReason; GetOORSession/ListOORSessions then expose only that string. That means swapdk/custodial callers still cannot route on the new transient code with errors.As or the enum without string-matching the failure reason, which is the recovery split this code is meant to provide. Please persist/expose the reject code (or a typed failure cause) through the session status/daemon API instead of only constructing a local typed error here.

Useful? React with 👍 / 👎.


case oorpb.OORRejectCode_OOR_REJECT_UNSPECIFIED:
// Unspecified rejection codes have no typed
// mapping; fall through to pass-through.
Expand Down
85 changes: 85 additions & 0 deletions oor/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package oor

import (
"errors"
"fmt"
"testing"

"github.com/lightninglabs/darepo-client/rpc/oorpb"
"github.com/stretchr/testify/require"
)

// TestClassifySubmitError verifies each typed operator rejection code maps to
// its client-facing typed error, that the user-balance rejection is distinct
// from the output-policy one (they have opposite retry semantics), and that
// untyped errors pass through unchanged.
func TestClassifySubmitError(t *testing.T) {
t.Parallel()

t.Run("user balance maps to typed error", func(t *testing.T) {
t.Parallel()

rejected := &oorpb.SubmitRejectedError{
Code: oorpb.OORRejectCode_OOR_REJECT_USER_BALANCE,
Reason: "user balance exceeds maximum",
}

got := ClassifySubmitError(rejected)
require.ErrorIs(t, got, &ErrUserBalanceExceeded{})
require.Contains(t, got.Error(), "user balance exceeds maximum")
})

t.Run("user balance distinct from output policy", func(t *testing.T) {
t.Parallel()

balance := ClassifySubmitError(&oorpb.SubmitRejectedError{
Code: oorpb.OORRejectCode_OOR_REJECT_USER_BALANCE,
})
policy := ClassifySubmitError(&oorpb.SubmitRejectedError{
Code: oorpb.OORRejectCode_OOR_REJECT_OUTPUT_POLICY,
})

// A balance rejection must not be mistaken for a (terminal)
// output-policy rejection, since only the latter requires
// restructuring the outputs.
require.NotErrorIs(t, balance, &ErrOutputPolicyViolation{})
require.NotErrorIs(t, policy, &ErrUserBalanceExceeded{})
Comment on lines +45 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The testify library does not standardly support NotErrorIs in many of its widely-used versions, which can lead to compilation failures depending on the project's testify dependency version. It is safer and more backward-compatible to use require.False with errors.Is instead.

Suggested change
require.NotErrorIs(t, balance, &ErrOutputPolicyViolation{})
require.NotErrorIs(t, policy, &ErrUserBalanceExceeded{})
require.False(t, errors.Is(balance, &ErrOutputPolicyViolation{}))
require.False(t, errors.Is(policy, &ErrUserBalanceExceeded{}))

})

t.Run("output policy still maps", func(t *testing.T) {
t.Parallel()

got := ClassifySubmitError(&oorpb.SubmitRejectedError{
Code: oorpb.OORRejectCode_OOR_REJECT_OUTPUT_POLICY,
Reason: "output 0 exceeds the per-VTXO maximum",
})
require.ErrorIs(t, got, &ErrOutputPolicyViolation{})
})

t.Run("wrapped rejection is unwrapped", func(t *testing.T) {
t.Parallel()

// errors.As must reach the typed rejection even when it is
// wrapped, so the daemon's outer context does not hide the
// code.
wrapped := fmt.Errorf("submit failed: %w",
&oorpb.SubmitRejectedError{
Code: oorpb.
OORRejectCode_OOR_REJECT_USER_BALANCE,
})

require.ErrorIs(
t, ClassifySubmitError(wrapped),
&ErrUserBalanceExceeded{},
)
})

t.Run("nil and untyped pass through", func(t *testing.T) {
t.Parallel()

require.NoError(t, ClassifySubmitError(nil))

plain := errors.New("connection reset")
require.Equal(t, plain, ClassifySubmitError(plain))
})
}
14 changes: 12 additions & 2 deletions rpc/oorpb/oorwire.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions rpc/oorpb/oorwire.proto
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ enum OORRejectCode {
// must restructure the outputs (smaller amounts, more outputs)
// before resubmitting.
OOR_REJECT_OUTPUT_POLICY = 2;

// OOR_REJECT_USER_BALANCE indicates the submit would push a recipient
// mailbox's aggregate VTXO balance above the operator's advertised
// MaxUserBalance cap. Unlike OOR_REJECT_OUTPUT_POLICY, retrying the
// same shape can succeed once the recipient's balance drops (e.g. it
// spends or refreshes down), so a custodial sender may hold the value
// and retry later rather than restructuring the outputs.
OOR_REJECT_USER_BALANCE = 3;
}

// SubmitPackageRejection carries a typed rejection of an OOR submit.
Expand Down
Loading