Skip to content

fix: allowance follow-ups - #1141

Merged
ovitrif merged 28 commits into
masterfrom
fix/allowance-follow-ups
Oct 28, 2026
Merged

ovitrif merged 28 commits into
masterfrom
fix/allowance-follow-ups

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1137
Fixes #1103
Fixes #1089
Fixes #1090
Fixes #1016

Twin: synonymdev/bitkit-android#1707
Twin: synonymdev/bitkit-android#1708 (the restore part)

This PR:

  1. Loads allowance contacts and the allowance identity as soon as the Paykit session is ready
  2. Announces every allowance the wallet accepts by itself, once, in one notice per refresh, naming each contact once
  3. Releases a failed automatic payment's payment list only once
  4. Keeps a bitcoin request that an allowance can't pay over Lightning on the manual flow
  5. Keeps Auto-paid labels and totals correct past 200 allowance attempts
  6. Says an allowance PIN check confirms the allowance change, not a payment
  7. Keeps allowance labels and open attempts across a wallet restore, without letting an unreadable entry fail the wallet backup

Description

Out of Scope

Design

N/A — no design available.

Preview

Allowance journeys on two iOS simulators (payer left, payee right), head of this PR against a local regtest; 2x speed.

Set and accept PIN gates the set step
set-and-accept.mp4
pin-gates-set.mp4
Auto-pay asks for no PIN PIN gates the end step (payer)
auto-pay-no-pin.mp4
pin-gates-end.mp4
Ended allowance asks Monthly cap reached
ended-asks.mp4
monthly-cap.mp4

QA Notes

Setup

Allowances are unreleased: a wallet that already holds allowances from an earlier build loses its local allowance state (USD limit labels, journal) on upgrade, since the stored format gained a field. Start with fresh allowances.

Two instances set up as in journeys/allowances/README.md, each other saved as linked contacts, the payer with a usable channel and over 50,000 sats spending, the payee with receiving capacity. Staging Blocktank no longer opens channels, so the runs here used a local synonymdev/bitkit-docker regtest (marketplace profile: local Pubky testnet and homeserver, Paykit server, bitcoind, Electrum, LND with the LNURL channel fixture) and an E2E build: SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG E2E_BUILD" E2E_BACKEND=local E2E_NETWORK=regtest E2E_HOMESERVER_PUBKY=8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo. Each wallet took a 1M sat channel with 500k pushed from the fixture's GET /generate/channel (its LND_P2P_ADDRESS must name the port the simulators reach LND on). Amounts are entered in USD.

Journeys

  • J1: updated pin-gates-allowance-changes.xml — the PIN screen for an allowance change says it confirms the allowance change; payee and payer checks split
  • J2: updated monthly-cap-reached.xml — wording: the payee's wallet accepts by itself
  • J3: updated end-stops-auto-pay.xml — wording: the payee's wallet accepts by itself
  • J4: set-and-accept.xml (unchanged) — regression: the payee's "Allowance Added" shows once after the automatic accept
  • J5: new restore-keeps-history.xml — after Reset And Restore, the payer's allowance row, its paid-automatically total and the "Auto-paid" tag come back

Manual Tests

  • 1. Payer cold start with a linked contact, Contacts never opened → Subscriptions → Allowances → Add Allowance → Choose Contact → the contact is listed, and Save shows "Waiting for an answer" — cold start before any other screen not in Capabilities
  • 2. Payee with notifications off, two payers each set an allowance while the payee's app is closed → open the payee's app → one "Allowance Added" toast names both payers — two concurrent payer instances not in Capabilities

Automated Checks

  • added PaykitAllowanceExecutorTests.swift — a settlement and recovery that both record one failure release the payment list once, in either order
  • added PaykitAllowanceExecutorTests.swift — a list version that cannot be journaled, or an attempt whose journal entry was never saved, never consumes the list; the list is consumed as its attempt, and a release that could not be saved is retried by the next recovery
  • updated PrivatePaykitServiceTests.swift — a wallet restore brings back a consumed list's owner, and a backup from before list owners still decodes
  • added PaykitPaymentRequestServiceTests.swift — only the attempt that consumed a list gives it back, a manual payment's consumption of the same version is left alone, a list consumed since its version was read is not consumed over, and saved contact state from before list owners still decodes
  • added PaykitAllowanceExecutorTests.swift — a list released before broadcast is not released again when clearing its claim fails, and a failure that can't be saved is released once by the next recovery
  • added PaykitAllowanceExecutorTests.swift — a bitcoin request with only USDT or on-chain endpoints is not covered and reaches the Payment Request sheet
  • added PaykitAllowanceExecutorTests.swift — Auto-paid labels and totals count all 250 automatic payments in the SDK history, and only the signed-in identity's
  • added PaykitAllowanceExecutorTests.swift — an offer whose accept was cut short after the SDK committed it is announced once on the next refresh
  • added PaykitAllowanceExecutorTests.swift — an offer whose mark fails to save is announced after the next refresh, and one whose clear fails is not announced again
  • added PaykitAllowanceExecutorTests.swift — signing out or switching profiles forgets which offers were announced, so a new session announces an offer whose mark is still stored
  • added PaykitAllowanceExecutorTests.swift — recovery settles a restored submitted attempt as paid when the node reports it paid
  • added PaykitAllowanceExecutorTests.swift — recovery during an automatic payment's send leaves its attempt alone, so the payment ends Sent, not Failed
  • added PaykitAllowanceExecutorTests.swift — recovery leaves a restored attempt open while the node has no payment history yet, then settles it once the node reports it paid
  • added PaykitAllowanceTests.swift — the allowance state backup keeps labels and the journal in the shared wire format and restores to the same state
  • added PubkyProfileManagerTests.swift — the automatic Paykit session is nil during a session change and readable when the change is announced as settled

Evidence

  • J1 to J5 on e71b591 (local regtest, two iPhone 16 simulators, iOS 26.5): all pass, with recordings: fix: allowance follow-ups #1141 (comment) . J5 brought back the allowance rows, PAID AUTOMATICALLY and the "· Auto-paid" tag after Reset And Restore. The later commits change recovery edge cases (unit tests) and journey text.

Earlier runs:
Device runs used an app build at 99b02d1 on two iPhone 16 simulators, iOS 26.5. Later commits make the allowance backup best-effort, restart a session start cut short by a same-key session, and merge restored state; they are covered by unit tests, not re-run on devices.

  • Unit tests: BitkitTests with CI's skip list passed on the final head aa2b648 (2,894 tests). The new release and coverage tests fail when their fix is reverted.
  • J4 (local regtest): the payee's wallet accepted the offer by itself; "Allowance Added · Ana set up an allowance for you" showed once and did not repeat; both rows turned Active. Also on staging regtest: accepted within about 20 s.
  • J1 (local regtest): all steps pass. The PIN pad reads "Please enter your PIN code to confirm this allowance change."; back kept the Set sheet and the payee got nothing; Save with the PIN showed "Waiting for an answer", then the payee's notice 4 s later and Active on both. A $2 request was paid automatically 11 s after the payee sent it, with no PIN pad. Ending asked for the PIN; back left it Active; ending with the PIN showed "Ended · $2.00 paid automatically".
  • J2 (local regtest, $5 a payment, $10 a month): two $4 requests were paid automatically about 10 s after sending; the third raised "Limit Reached · A $4.00 request from Leo is above your allowance. Review it to pay." and opened the Send screen.
  • J3 payee half (local regtest): after the allowance ended, a $2 request opened the payer's Send screen; nothing was paid automatically.
  • Manual 1: on staging regtest, a cold start listed the contact and Save proposed, on this head and on master alike (see Gaps).

Gaps

  • bug: allowance contact picker is empty and saving fails until contacts are opened #1137: master did not reproduce it on a plain cold start. This PR fixes the cause the issue describes (the session not yet ready when the profile turns authenticated, as during a sign-in, a wallet or session restore, or a profile deletion), covered by PubkyProfileManagerTests.swift; the reporter's exact trigger was not reproduced.
  • Manual 2 (two payers at once) not run: it needs a third instance.
  • Runs used an E2E build against a local regtest because staging Blocktank no longer opens channels.

Models used

  • Planning/scoping: claude-opus-5-5 (reasoning: 10)
  • Implementation: claude-opus-5-5 (reasoning: 10)
  • Review: claude-opus-5-5 (reasoning: 10)
  • Review rounds: 2

Reasoning values are the harness's effort setting for these sessions; the reviewer was an independent subagent with fresh context.

A settlement and restart recovery could both record one failed attempt and release the payee's payment list twice, and a list released before broadcast was released again when clearing its claim failed to save.
The Auto-paid label and the amount paid automatically now come from the Paykit SDK's accounting history, which keeps every attempt, instead of the local journal, which keeps only the newest 200.
A bitcoin request whose accepted endpoints are only USDT or on-chain was held for an allowance that can never pay it, waiting on a usable channel and keeping it off the Payment Request sheet. Coverage now requires an accepted Lightning endpoint.
…ears

Signing in, restoring or ending a profile deletion turns authState authenticated while the session change is still in flight, so allowances never got their identity and contacts never loaded until Contacts was opened. The profile manager now announces when a session change settles, and the app activates allowances and loads contacts then.
An offer is marked before the automatic accept and unmarked once announced, so an accept that a stop or a cancelled refresh cut short is announced on the next refresh. Offers accepted in one refresh share one announcement, since each fallback toast replaced the one before it. The shared allowance journeys now say the payee's wallet accepts by itself.
Setting, answering or ending an allowance asked for the PIN on the payment PIN screen, whose text said it confirms sending a payment. Those checks now say they confirm an allowance change.
@ovitrif ovitrif added this to the 3.1.0 milestone Oct 27, 2026
@ovitrif ovitrif self-assigned this Oct 27, 2026
When the journal can't save an attempt's outcome, the entry stays open and a later settlement or recovery acts on it once, instead of releasing the list now and again after a relaunch.
The accounting history is scoped by the local identity; payments of another identity no longer show as Auto-paid, and a failed read keeps the labels and totals already shown.
The start marked the identity as started before it ran, so a deletion or session change that stopped it left contacts unloaded until sign-out.
Also matches the shared allowance journeys' wording to the Android twin.
The allowance state (USD limit labels and the attempt journal recovery verifies from) lived only in the keychain, so a restored wallet lost its labels and left open attempts unverifiable. It is now part of the Paykit payment state backup, in a format both apps share, and changes to it schedule a backup.
@ovitrif

ovitrif commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 99b02d1:

  • A failed payment's list is released only after its outcome is saved, so a failed save can't lead to a second release after relaunch.
  • Auto-paid history counts the signed-in identity only, and a failed ledger read keeps the labels already shown.
  • The allowance start runs again when a session change cut it short.
  • A combined "Allowance Added" notice names each contact once; journey wording now matches the Android twin.
  • Allowance labels and the attempt journal are now in the Paykit payment state backup (iOS twin of bug: allowance history and open attempts are lost after a wallet restore bitkit-android#1583).

Allowance, profile manager and backup suites pass on this head; the full unit suite passed at cc24f0e.

Allowance state rides in the wallet backup, so reading, restoring or capturing it is now best-effort: an entry this version can't read is skipped and logged, and the rest of the wallet backup and restore go ahead.
…ge restored state

A same-key session that appeared while a start waited now runs its own start. Restoring allowance state keeps local identities the backup lacks, backup identity keys are normalized as on Android, and the combined Allowance Added notice names each contact once by key.
@ovitrif

ovitrif commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed aa2b648:

  • Allowance state in the wallet backup is now best-effort: an entry this version can't read is skipped and logged, and a read, restore or capture error never fails the rest of the wallet backup or restore.
  • Restoring keeps local identities the backup lacks, and backup identity keys are normalized as on Android.
  • A same-key session that appears while an allowance start is still waiting now runs its own start.
  • The combined "Allowance Added" notice names each contact once, by key.
  • The changelog entry covers the restore fix; the allowances journey README notes how requests are priced on each platform.

The device run of the allowance journeys and the QA notes are in the description. BitkitTests passes on this head.

@ovitrif
ovitrif marked this pull request as ready for review October 27, 2026 03:29
@ovitrif
ovitrif requested review from a team, ben-kaufman and pwltr and removed request for a team October 27, 2026 03:29
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T03:32:41.260107Z aa2b648 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@talosmachina talosmachina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 finding (1 P1). Follow-ups to allowances: wallet-backup support for allowance state in the format shared with Android, a session that appears mid-start restarting allowances, restored state merging with local identities, and the combined Allowance Added notice. Reviewed aa2b648c, full tier. Reasoned from the code and CI; the iOS app does not build on this reviewer's Linux host.

What I checked, and 5 candidates I ruled out

Read: PaykitAllowanceStateBackup.swift, PaykitPaymentStateBackup.swift, PaykitAllowanceExecutor.swift (store, recovery, autoPay, manual payment), PaykitAllowanceManager.swift, the backup restore path in BackupService.swift, and the new tests in PaykitAllowanceTests.swift. Cross-checked against the Android counterpart in synonymdev/bitkit-android#1708 (PaykitAllowanceStateBackup.kt, BackupRepo.restoreWalletBackup).
CI: SwiftFormat, build-local and validate green; Run Tests, Integration Tests and e2e still pending at review time.

Ruled out

  • Backup keyed by a non-normalized identity: the Paykit FFI returns every public key through to_app_key() (pubky + z32), and publicKeyFromSecretKey normalizes, so the identity keys and the store keys agree.
  • Restore drops local identities: the head commit merges restored identities into the local store instead of replacing it.
  • Unknown stage fails the whole wallet backup on iOS: PaykitAllowanceStateBackups decodes each identity through Lenient, and the new test covers it.
  • billingPeriodStartsAt omitted when nil: Android's PaykitPaymentRequestId gives it a null default, so an absent key decodes.
  • USD labels lose precision across platforms: iOS writes Decimal strings, and Android parses them with BigDecimal, so the strings round-trip.
  • Cold reader: not run on the finding below, given the run budget. The finding rests on an executed decode instead.

Merge confidence: 3/5, the P1 on cross-platform restore of a manual-payment journal entry.

Comment thread Bitkit/Models/PaykitAllowanceStateBackup.swift
@ovitrif

ovitrif commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Out of scope, parity only: announcedOfferIds is not cleared on deactivate() or an identity change, while Android clears it.

@jvsena42 fixed in 08225c9 instead of deferring, since this PR added the set. deactivate() and an identity change in activate(identity:) now clear announcedOfferIds, next to manualRequestIds, as Android does. Signing out or switching profiles now forgets which offers were announced, so an offer announced under one identity cannot hide another identity's offer with the same id.

A new test, testManagerForgetsWhatItAnnouncedWhenDeactivated, checks that a new session announces an offer whose mark is still stored. The executor, allowance and payment-proof suites pass.

@ovitrif
ovitrif requested a review from jvsena42 October 27, 2026 15:53
@ovitrif

ovitrif commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed e71b591: a payment list consumed by an automatic payment is now given back after a restart.

@ben-kaufman raised this on the Android twin (comment on synonymdev/bitkit-android#1707), and iOS had the same gap. The list was consumed before its version was journaled. A restart between the two writes left recovery with no version, so it recorded the failure and never gave the list back. The same happened when the rollback before broadcast failed to save.

  • The list version is journaled before the list is consumed. A journal save that fails stops the attempt before anything is consumed.
  • The consumed list records the automatic attempt that consumed it, and only that attempt can give it back. A second release, or a release for a list the attempt never consumed, cannot clear another payment's consumption. The consume also fails if the list changed since its previous version was read.
  • A release that cannot be saved keeps the version in the journal. The next recovery retries it, before it waits for the node.
  • The owner stays on this device and is not part of the wallet backup, as on Android. After a restore, a failed automatic attempt leaves its list consumed until the payee publishes a newer one.

New tests cover a restart before the consume, the owner checks, a consume over a changed list, and a release retried after a failed save. The executor, allowance, payment proof, payment request and private Paykit suites pass.

@ovitrif
ovitrif requested review from piotr-iohk and removed request for jvsena42 and pwltr October 27, 2026 17:07

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed to 08225c9. announcedOfferIds is now cleared on sign-out and on a profile switch (PaykitAllowanceManager.swift:135, :155), with a test, which matches Android. No new findings.

Device gate (partial): 08225c9, not run yet. Pending: J1–J5, including restore-keeps-history.xml, paired with the Android twins.

Comment thread Bitkit/Services/PaykitAllowanceExecutor.swift Outdated
@ovitrif

ovitrif commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 3335054 and 73a4fcf.

  • 3335054 answers @ben-kaufman's missing journal entry. The save before the consume throws when the attempt has no journal entry, so a lost initial journal write can no longer leave a consumed list that recovery never finds.
  • 73a4fcf keeps a consumed list's owner through the wallet backup, matching @ben-kaufman's ask on the Android twin (comment on synonymdev/bitkit-android#1708). The private Paykit backup carries an optional owner map next to the versions, and restore sets it, so a restored failed attempt can still give its list back. Older backups decode with no owners.

The executor, allowance, private Paykit and payment request suites pass.

@ovitrif

ovitrif commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

QA

@jvsena42's pending device gate (J1 to J5), run on e71b5916: an E2E Debug build on two iPhone 16 simulators (iOS 26.5) against a local bitkit-docker regtest stack with a Pubky testnet homeserver and Paykit Server. Each wallet had a 1,000,000 sat channel, and the payer had notification permission granted. The later commits 3335054 and 73a4fcf only change recovery edge cases and are covered by unit tests; f085773 changes journey text only.

  • J1: pin-gates-allowance-changes.xml — pass.
    • With a PIN and "Require PIN for payments" on, the PIN pad read "Please enter your PIN code to confirm this allowance change." for both Save and End.
    • Going back kept the Set sheet, and the payee got no notice. With the PIN, the payee's wallet accepted by itself, and a $2 request paid with no PIN pad.
  • J2: monthly-cap-reached.xml — pass.
    • Two $4 requests paid automatically, each posting "Payment Executed". The third raised "Limit Reached" over the $4.00 Payment Request sheet. On this head the notification still shows in the foreground; fix: skip duplicate banners in the foreground #1151 changes that.
    • PAID AUTOMATICALLY read $8.01, because the first request converted to $4.01 at the current rate.
  • J3: end-stops-auto-pay.xml — pass.
    • Ending showed "Ended · $8.00 paid automatically" and the ended text. Requests after an end from either side waited in the bell as ordinary Payment Requests.
    • The step for an allowance that paid nothing expected "Ended · $0.00 paid automatically". Both apps show plain "Ended" then, so f085773 updates the journey.
  • J4: set-and-accept.xml — pass. The payer's row went from "Waiting for an answer" to Active on both sides, and the payee showed "Allowance Added" once, with no review sheet.
  • J5: restore-keeps-history.xml — pass.
    • After a $2 automatic payment and a backup made after it, Reset And Restore from the recovery phrase brought back the Active row and every Ended row, PAID AUTOMATICALLY $2.00, and the "· Auto-paid" tag on the request.
    • Seen in passing: a Payment Request dismissed before the reset came back as pending after the restore, and its sheet opened.
J1: PIN gate J2: two paid J2: over the cap
j1-payer2x.mp4
j2-payer2x.mp4
j2b-payer2x.mp4
J3: end stops automatic payments J4: payee, one "Allowance Added"
j3-payer2x.mp4
j4-payee2x.mp4

The recovery phrase is blurred in the first J5 recording and cut from the second.

J5: paid, backed up J5: reset, restore, history kept
j5a-payer2x-masked.mp4
j5b-payer2x-cut.mp4

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed to f085773. No new findings.

  • Releases are owner-checked and become no-ops after the first.
  • A list version is cleared only after its release succeeds.
  • The new claim lock is taken only in consume and release, with no re-entry or cross-lock.
  • Restore writes the ledger and claims together before any recovery can read the journal.
  • Backups stay compatible both ways: v3.0.0 ignores the new owner field, and older backups default to an empty owner. The key matches across Android and iOS.

Device gate (partial): f085773, not run yet. Pending: the allowance journeys paired across both platforms, including restore-keeps-history.xml.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: full review of the complete PR diff against its merge base b8b7f3d, at f085773. There was no earlier baseline, so no coverage is carried over. The review covered: allowance activation once the Pubky session settles (AppScene, PubkyProfileManager); offer marking and the combined "Allowance Added" notice; writing the journal before consuming the list, and releases that only the owning attempt can make; node-backed recovery of restored attempts and the new recovery run when the node starts; Lightning-only coverage; Auto-paid totals from the SDK ledger; the allowance PIN message; allowance-state backup, restore and backup scheduling; translations; and journeys. Shared behavior and the backup wire format were compared with the Android twins bitkit-android#1707 at 2122f57 and bitkit-android#1708 at bc5f8ef.

No new actionable code findings.

  • The allowance backup reads correctly in both directions with #1708. Identity keys are the same pubky-prefixed form on both sides; the Paykit FFI returns keys via to_app_key() at paykit-rs dd8e75e, the revision pinned in Package.resolved. Stages are lowercase, timestamps are RFC 3339 with milliseconds, and empty optionals are written as explicit nulls.
  • I checked the earlier review concerns against this head's source, and all are fixed: the explicit-null allowanceId (thread), the failed pre-accept mark and the repeated notice (thread, thread), recovery before the node has payment history (thread), and consuming a list for an attempt that was never journaled (thread).
  • A remaining risk, not a finding: recovery now also runs on every node .running transition (AppScene.swift:1256), and recover does not skip requests the executor is currently paying (inFlightRequestIds). Suppose the node restarts, for example after "Reset payment routing caches", while an automatic payment is between beginPaymentExecution and .sending. Recovery could then record it as failed while the send goes ahead, because setStageDurably does not check for a terminal stage. I couldn't show that this timing can actually happen. Skipping in-flight requests in recover would close it.

Non-blocking consistency suggestions

  • iOS mayCover holds a request from any contact with a proposed or active allowance. Android's version (#1707) also requires a Lightning endpoint. On iOS, a request with only USDT or on-chain endpoints therefore waits for the refresh in progress before its sheet opens. Adding the same Lightning check would match the two.

Validation: CI passed at this head: Run Tests, Integration Tests, e2e-tests-local and SwiftFormat. This review is source analysis only; I did not build the app or run any tests locally. The author reports J1–J5 passing on e71b5916 (comment); later commits are covered by unit tests only.

Device testing: not performed in this review. Ready for device testing.

Suggested additional test cases

  • iOS, J5 setup (payer with the $5 a payment / $50 a month allowance Active): after Reset And Restore, open the payee's allowance row and detail sheet. Expected: the limits read exactly $5 a payment and $50 a month, not an amount converted at the current rate. On iOS, PAID AUTOMATICALLY and "Auto-paid" now come from the SDK ledger, so J5 passes even if the new local allowance backup did not restore. The USD labels are the only visible output of that backup.
  • Restore across platforms. Setup: an Android payer on the #1708 build with an Active allowance, one automatic payment and one incoming request paid by hand. Wait for the wallet backup, then restore the phrase on iOS at this head. Expected: the allowance row is Active with the same USD limits, and PAID AUTOMATICALLY and "· Auto-paid" are shown. Repeat in the other direction (iOS backup restored on Android #1708).
  • iOS: run the existing restart-never-pays-twice.xml at this head. It is not in the PR's journey list, but recovery changed here: submitted attempts are now settled from the node, and recovery reruns when the node starts. Expected: the interrupted payment is paid once, and "Payment Executed" and the Auto-paid total appear once.

Reviewer model: claude-opus-5-5
Reasoning effort: high

Recovery also runs when the node starts, so a node restart during an automatic payment could record its attempt as failed while the send went ahead. Recovery now skips requests the executor is paying.
@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed f3b5825.

@piotr-iohk

Skipping in-flight requests in recover would close it.

Done in f3b5825. recover now skips every occurrence whose request the executor is paying, checked per occurrence, so a payment that starts while recovery is awaiting is skipped too. The new test testRecoveryLeavesTheAttemptOfAPaymentInProgressAlone runs recovery while a send is held, with the prepared attempt in the SDK history. Without the fix, the attempt ends Failed. With it, the attempt ends Sent. The BitkitTests suite passed except one hardware-wallet transfer test, which passed when rerun on its own.

Adding the same Lightning check would match the two.

out of scope: tracked in #1159, because mayCover predates this PR and the only effect is a short wait before the sheet opens, not a wrong payment.

Suggested additional test cases

Not run on this head. The restart case is covered by the unit tests on the recovery paths, including the new one. The two restore cases need a device run.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Follow-up review of the changes since f085773 (baseline review), at f3b5825. The comparison base and merge base are unchanged at b8b7f3d. This pass covered recovery leaving an in-progress automatic payment alone, the new unit test, and the earlier review threads. The other baseline surfaces are inherited.

No new actionable code findings.

f3b5825 skips recovery for a request autoPay is still executing. That is the window the baseline review described: a node .running transition while the attempt is between beginPaymentExecution and the node handoff. inFlightRequestIds is set before autoPay's first await and cleared when payLightning returns. ensureReconciled leaves the ledger untouched while requiresReconciliation is false. At paykit-rs dd8e75e, that flag is set by restore and invalidation, and reserve waits until reconciliation is clear, so a normal in-progress send is not reconciled again. A terminal Failed outcome cannot later be rewritten to Succeeded, which is why skipping that record matters.

Android still skips only the live attempt id after reservation (#1707 at 2122f57, #1708 at bc5f8ef). iOS skips the whole request. autoPay enters only while the request is still proposed, so a crashed post-accept attempt stays recoverable.

The earlier threads stay fixed. This diff leaves the explicit-null backup, offer marking, unavailable node history, and the journal-before-consume guard as they were.

Validation: Source analysis only. I inspected testRecoveryLeavesTheAttemptOfAPaymentInProgressAlone and did not run it. SwiftFormat passed at this head. Run Tests and Integration Tests were still in progress.

Recommended before device testing: Wait for Run Tests at this head. It includes the new check that recovery during the send records no outcome and leaves the journal Sent.

Device testing: not performed in this review.

Reviewer model: grok-4.7
Reasoning effort: high

piotr-iohk commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Device test results — scoped smoke

Passed at f08577398630bc86750f19e1339bc34c8c95762d, tested 28 October 2026 on the existing iPhone 17 simulator, iOS 26.0. Paired with Android #1707 at 2122f57dcdf0b330b8034ad9807d65be1e14f169.

Clean dev installs with separate fresh wallets and Pubky profiles, linked as contacts. Bitcoin used regtest; USDT used the existing local EVM fork. Limits were $5 per payment / $50 per month. No Lightning channel was needed for the exercised scope.

Observed on iOS:

  • After cold start, the linked contact appeared in Add Allowance without opening Contacts first; saving reached the outgoing offer state and then Active.
  • Save displayed “Please enter your PIN code to confirm this allowance change.” Cancelling preserved $5/$50 and sent no offer; entering the test PIN saved it and both sides became Active.
  • An incoming offer was accepted without a recipient review or PIN. With notifications off, one “Allowance Added” toast appeared. No repeat was observed across the acceptance/relaunch recording (430 seconds).
  • Payer End used the allowance-change PIN wording. Cancelling kept Active; authorizing changed both copies to Ended.
  • Recipient short swipes cancelled; a full swipe ended the allowance on both copies without a payer PIN.
  • With the allowance Active, a USD-priced $1 request accepting on-chain only opened the manual amount/confirmation flow. Explicit swipe and payment PIN sent 1,238 regtest sats, received on Android. “Paid Automatically” remained $0.00. Because the request was USD-priced, this observation does not isolate the BTC-priced on-chain-only allowance condition.
  • The Android-to-iOS local USDT payment was also received. All four test allowances ended on both apps; the temporary payment-PIN requirement was restored to Off.

This exercised the UI portions of J1/J3, the single-offer acceptance/notice behavior in J4, and manual case 1. J1/J3 were not executed end to end. Automatic Lightning payments, monthly-cap exhaustion, interruption/recovery, manual case 2 (multiple payers), J5 restore and cross-platform restore were not run. This is a scoped device result, not a complete PR device pass.

Revision boundary: the newer f3b582580e9ba665ae37219a2aea0e20974ba460 was not installed or device-tested in this run. Its delta changes recovery to skip requests still being paid and adds testRecoveryLeavesTheAttemptOfAPaymentInProgressAlone. It does not change the exercised UI flows, so it does not call for repeating this whole smoke pass. Validation of recovery during a live automatic payment remains separate; the controlled regression test covers that interleaving, while a device node-restart check belongs to the deferred automatic-payment scope.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed to f3b5825. No new findings.

f3b5825 makes recovery skip any ledger occurrence whose request is in inFlightRequestIds (PaykitAllowanceExecutor.swift:489). Before it, a recovery pass on a running node could record a terminal Failed on an automatic attempt that sat between journal(.prepared) (:850) and setStageDurably(.sending) (:950). paykit-rs can't rewrite Failed, so that attempt's capacity could be overspent.

I checked:

  • The marker is set at :738 before any await and removed by defer after the unstructured admitAndPay task completes, so cancelling the caller doesn't remove it early.
  • Recovery never pays, so this can't pay twice.
  • The set is in memory and empty after a restart, so an attempt that crashed still settles through the node as before.
  • The new test fails on both assertions without :489.

Device gate (partial): f3b5825, not run yet. Pending: the allowance journeys paired with the Android twin synonymdev/bitkit-android#1708, including restore-keeps-history.xml and restart-never-pays-twice.xml.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved at f3b582580e9ba665ae37219a2aea0e20974ba460, following the full code review at f085773, the follow-up review at f3b5825, and the targeted device run below. Current unit, integration, lint, translation and E2E workflows passed; all five inline review threads are resolved.

Device test results — automatic-payment follow-up

Tested 28 October 2026 on the existing iPhone 17 simulator, iOS 26.0, using a rebuilt, clean-installed Debug E2E app at the exact revision above. Paired with Android #1707 at 2122f57dcdf0b330b8034ad9807d65be1e14f169.

Local Docker regtest, distinct fresh wallets and Pubky profiles, mutually saved contacts, contact payments enabled, and active $5/payment / $50/month allowances. Each wallet had a local Lightning channel with 500,000 sats pushed. PIN was disabled and iOS notifications were off.

  • Automatic under-limit: iOS paid a BTC-priced 2,000-sat request without payer interaction. Android received 2,000 sats once, with a 1-sat routing fee. The request showed Auto-paid and “Paid Automatically” became $1.61. iOS also received Android’s 2,000-sat automatic baseline payment.
  • Recovery overlapping a live automatic attempt: the unchanged Debug binary was observed with LLDB. A temporary local proxy hold paused the private-inbox refresh after reservation/journaling, and the real recover(identity:) method was scheduled through LLDB. The node was restarted through Dev Settings while the attempt remained live. After releasing the hold, recovery reached the new line-489 guard with the same request ID still in inFlightRequestIds; it skipped that request’s attempt loop. Older requests did enter the loop in the same pass. The live request then paid once and retained its Auto-paid tag and accounting.
  • Across the baseline and two restart probes, iOS sent three separate 2,000-sat payments, and its total became $4.83. Android received each once. After force-stop/relaunch of both apps, all four request rows and the totals remained correct; channel sent/received/update counters stayed unchanged with zero pending HTLCs. No incorrect Failed state or duplicate transfer was observed.

Instrumentation boundary: the first UI-only node restart did not dispatch allowance recovery and therefore did not prove the overlap. The successful overlap used debugger-invoked recovery; no ledger, outcome or in-flight IDs were injected, and no app source was changed. Automatic lifecycle dispatch and recovery during an already-submitted HTLC are not claimed. The debugger was detached and temporary proxy rules removed before relaunch.

This adds current-revision payment/recovery evidence to the earlier UI/manual-flow smoke at f085773. It remains targeted coverage: cap exhaustion, restore, history trimming, multiple concurrent payers and PIN-protected automatic payment were not rerun. Approval does not represent a complete device-matrix pass.

Reviewer model: gpt-6.1-sol
Reasoning effort: high

@ovitrif
ovitrif merged commit 25f8225 into master Oct 28, 2026
19 checks passed
@ovitrif
ovitrif deleted the fix/allowance-follow-ups branch October 28, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

5 participants