Repository navigation
ENG-660: reject and disable payment splits to inactive destination accounts - #527
Merged
islandbitcoin merged 7 commits intoOct 12, 2026
Merged
Conversation
…ctive walletSplitsSet now loads each destination's account and refuses it with SplitDestinationInactiveError unless its current status is active, using the same AccountValidator check the intraledger send applies to the recipient. The set stays all-or-nothing: one inactive destination saves nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow
…nactive When a payout fails with InactiveAccountError and the destination account is inactive right now (looked up directly, since the error does not say which side failed), the executor fails the row, turns the rule off with a conditional update, and pushes the owner a "split turned off" notice only from the call that flipped it. A source-account InactiveAccountError keeps the rule enabled and pushes the usual failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow
Covers the record path shared by the on-receive webhook and the reconcile cron, and disableById against Mongo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow
The resolver fixture now answers the destination account lookup that setPaymentSplits performs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow
…n, never drop the push A save re-inserts every split rule with a new id and a delete removes it, so an execution's ruleId can name a rule that no longer exists. Disabling by that id matched nothing and the owner got no push at all, while a replaced rule to the same closed destination stayed enabled. Disable by (walletId, destinationWalletId) instead, and when nothing was disabled and no rule to that destination exists any more, send the ordinary "failed" push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow
…llet The scoping assertion ran against a wallet with no rules, so it passed even with the walletId filter removed. Give wallet-g a real enabled rule to the same destination, disable wallet-f's, and assert wallet-g's rule is still enabled. Verified the test fails with the filter removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
TEST E2E for ENG-653 (#526) found that
walletSplitsSetaccepted a destination whose account's latest status wasclosed. Every later receive then failed at payout withInactiveAccountErrorand pushed the owner a "split failed" notice, with no end.Change
setPaymentSplitsloads each destination's account (walletId or username path) and rejects it with the newSplitDestinationInactiveErrorunlessAccountValidator(account).isActive()passes. That is the same check the intraledger send applies to the recipient. The set stays all-or-nothing, so one inactive destination saves nothing. The error-map entry returns "Split destination account is not active".InactiveAccountError, the executor looks up the destination account directly, because the error does not say which side was inactive. If the destination is inactive, the row is markedfailedand the rule is turned off withdisableForDestination(walletId, destinationWalletId), anupdateManyon the wallet's enabled rules to that destination (enabled: true→enabled: false). It matches by destination, not by the execution'sruleId, becausereplaceForWalletre-inserts every rule with a new id on each save, so a recordedruleIdcan name a rule that no longer exists. Only the call that flipped a flag pushes the owner, using the newnotification.paymentSplit.disabledphrase (en + es) and thepayment_split_disableddata type, so several queued rows for one rule produce one push. When nothing flipped, the executor callsexistsForDestination: if no rule to that destination exists any more (edited away or deleted), or that lookup fails, the owner gets the ordinary failed push, so a failed payout is never silent. A source-accountInactiveAccountError, or a destination lookup that fails, keeps the rule enabled and sends the usual failure push. If the disable write itself fails, the owner gets the usual failure push and an error is logged.recordSplitExecutions, which already lists rules withenabledOnly: true, and the reconcile useslistWalletIdsWithEnabledRules. No change was needed. Tests now pin both.How it was tested
set-splits.spec: closed, locked and pending destinations are rejected through both the walletId and username paths, with nothing saved. An active destination still saves. A mixed set with one inactive destination saves nothing. An unreadable destination account is treated as not found.execute-pending-splits.spec: a closed, locked or pending destination disables the rule, fails the row and pushes the owner once. Two rows on one rule produce one push. A source-inactive error leaves the rule enabled. A failed destination lookup and a non-inactive error never disable the rule. A failed disable write falls back to the failure push. When the recorded rule no longer exists, the owner still gets the failure push. When nothing was disabled and theexistsForDestinationlookup fails, the owner gets the failure push.record-split-executions.spec: a disabled rule records nothing.wallet-splits-set.spec: the GraphQL error message for an inactive destination.payment-splits-repositories.spec, against a local docker mongo):disableForDestinationreturns true once, then false. An unknown destination returns false. After a re-save gives the rule a new id, the replacement rule is still disabled. The call is scoped to the wallet: another wallet's enabled rule to the same destination stays enabled (verified to fail if the walletId filter is removed).existsForDestinationis true for a disabled rule and false for a destination never configured. A disabled rule records nothing, and a wallet whose only rule is disabled drops out oflistWalletIdsWithEnabledRules.make unit-in-ci(293 suites, 3795 passed, 3 skipped),yarn test:integration(5 suites, 31 passed),yarn tsc-check-noimplicitany,yarn tsc-check,yarn eslint-check,yarn build,yarn check:sdl,yarn check-yaml,yarn madge-check, prettier on the changed files, andtypos --config typos.toml. All pass.Follow-ups
payment_split_disabledis a new pushtype. The app should route it likepayment_split_failed.Wallet.splitswithenabled: false. The client should show them as off and let the owner delete or replace them.🤖 Generated with Claude Code
https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow