diff --git a/oor/errors.go b/oor/errors.go index 8b0d3de0e..18a2121fc 100644 --- a/oor/errors.go +++ b/oor/errors.go @@ -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 @@ -118,6 +147,11 @@ func ClassifySubmitError(err error) error { Reason: rejected.Reason, } + case oorpb.OORRejectCode_OOR_REJECT_USER_BALANCE: + return &ErrUserBalanceExceeded{ + Reason: rejected.Reason, + } + case oorpb.OORRejectCode_OOR_REJECT_UNSPECIFIED: // Unspecified rejection codes have no typed // mapping; fall through to pass-through. diff --git a/oor/errors_test.go b/oor/errors_test.go new file mode 100644 index 000000000..8411ce0c0 --- /dev/null +++ b/oor/errors_test.go @@ -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{}) + }) + + 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)) + }) +} diff --git a/rpc/oorpb/oorwire.pb.go b/rpc/oorpb/oorwire.pb.go index c9be49f3e..6123176c9 100644 --- a/rpc/oorpb/oorwire.pb.go +++ b/rpc/oorpb/oorwire.pb.go @@ -45,6 +45,13 @@ const ( // must restructure the outputs (smaller amounts, more outputs) // before resubmitting. OORRejectCode_OOR_REJECT_OUTPUT_POLICY OORRejectCode = 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. + OORRejectCode_OOR_REJECT_USER_BALANCE OORRejectCode = 3 ) // Enum value maps for OORRejectCode. @@ -53,11 +60,13 @@ var ( 0: "OOR_REJECT_UNSPECIFIED", 1: "OOR_REJECT_LINEAGE_TOO_LARGE", 2: "OOR_REJECT_OUTPUT_POLICY", + 3: "OOR_REJECT_USER_BALANCE", } OORRejectCode_value = map[string]int32{ "OOR_REJECT_UNSPECIFIED": 0, "OOR_REJECT_LINEAGE_TOO_LARGE": 1, "OOR_REJECT_OUTPUT_POLICY": 2, + "OOR_REJECT_USER_BALANCE": 3, } ) @@ -725,11 +734,12 @@ const file_oorwire_proto_rawDesc = "" + "\x16final_checkpoint_psbts\x18\x02 \x03(\fR\x14finalCheckpointPsbts\"8\n" + "\x17FinalizePackageResponse\x12\x1d\n" + "\n" + - "session_id\x18\x01 \x01(\fR\tsessionId*k\n" + + "session_id\x18\x01 \x01(\fR\tsessionId*\x88\x01\n" + "\rOORRejectCode\x12\x1a\n" + "\x16OOR_REJECT_UNSPECIFIED\x10\x00\x12 \n" + "\x1cOOR_REJECT_LINEAGE_TOO_LARGE\x10\x01\x12\x1c\n" + - "\x18OOR_REJECT_OUTPUT_POLICY\x10\x022\xb1\x01\n" + + "\x18OOR_REJECT_OUTPUT_POLICY\x10\x02\x12\x1b\n" + + "\x17OOR_REJECT_USER_BALANCE\x10\x032\xb1\x01\n" + "\x11OORMailboxService\x12J\n" + "\rSubmitPackage\x12\x1b.oorpb.SubmitPackageRequest\x1a\x1c.oorpb.SubmitPackageResponse\x12P\n" + "\x0fFinalizePackage\x12\x1d.oorpb.FinalizePackageRequest\x1a\x1e.oorpb.FinalizePackageResponseB2Z0github.com/lightninglabs/darepo-client/rpc/oorpbb\x06proto3" diff --git a/rpc/oorpb/oorwire.proto b/rpc/oorpb/oorwire.proto index c7c4c4085..f44a07f2c 100644 --- a/rpc/oorpb/oorwire.proto +++ b/rpc/oorpb/oorwire.proto @@ -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.