Skip to content

feat: accept a reader's allowance on the server link before each request - #29

Draft
ovitrif wants to merge 16 commits into
masterfrom
feat/allowances
Draft

ovitrif wants to merge 16 commits into
masterfrom
feat/allowances

Conversation

@ovitrif

@ovitrif ovitrif commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Refs:

Summary

A reader who grants a creator an Allowance in Bitkit still had to swipe to pay each Locks unlock. The server never read the reader's messages on the shared Encrypted Link, so an Allowance the reader proposed there stayed unanswered. Only the server's paykit-server App can accept it: the reader names the creator identity as Allowee, and the server runs as that identity's delegated Paykit App.

#33 moved the server to the identity-wide shared runtime in Paykit v0.1.0-rc59 and is merged into master; this PR builds on it and targets master.

Change

  • During each Payment Request proposal handoff, after the link is ready, the server reads the reader's private messages and accepts each Allowance proposal that names the creator as Allowee, up to four per handoff. The acceptance is queued ahead of the Payment Request, so the wallet can pay the request under the Allowance.
  • Intake is best effort: a failure is logged (stage=allowance_intake) and the request is still proposed for manual payment.
  • Only proposals the reader sent, with consistent history and no response yet, are accepted. The server never proposes, rejects or ends an Allowance and keeps no Allowance accounting.

SDK

Paykit v0.1.0-rc59 (#33's pin) contains the Allowances stack, pubky/paykit-rs#158 to pubky/paykit-rs#161, so this PR adds no dependency pin of its own. Against the shared runtime in rc59:

  • An Allowance is bound to the two Pubky identities that own the link, not to receiver paths, so AllowanceFilter and accept_allowance take only the counterparty.
  • Allowance messages carry the sending app_id, and removing an App does not revoke an accepted Allowance.
  • The wallet must claim a received Payment Request for execution (claim_payment_request_for_execution) before it accepts the request automatically. The e2e wallet does this.

Tests

  • e2e allowances.rs allowance_proposed_on_the_server_link_covers_the_next_locks_invoice runs the production server, PostgreSQL and a Pubky testnet against a wallet on the shared-state SDK:
    • the reader proposes two Allowances on the link;
    • after the next Locks invoice, both are accepted, and the acceptances precede that invoice's first request in the server's outbound queue;
    • each invoice is proposed under one payment reference. The outbox hands a row off at least once, so a handoff that outlives its lease queues the same request again; the test counts references per invoice, not queued sends;
    • the covering Allowance admits the automatic payment, and the one below the lock price is blocked (amount_outside_range);
    • with the intake call removed, the test fails and the Allowance stays Proposed.
  • Unit tests:
    • paykit.rs only_received_consistent_allowee_proposals_are_accepted
    • tests/outbox.rs allowance_intake_runs_on_the_link_before_the_request
    • tests/outbox.rs failed_allowance_intake_still_proposes_the_request
  • Checks run locally on this head (a merge of master including Feat/lock payment draining #3's lock payment draining):
    • cargo test --locked -p paykit-server, companion-auth example tests, cargo clippy --locked --workspace --all-targets -- -D warnings and cargo fmt --all --check: pass
    • cargo test --locked -p paykit-server-e2e -- --test-threads=1 against PostgreSQL 16: full suite pass on head 00c74b4 (one earlier full run failed once in server_workflow.rs malformed_recovery_marker_keeps_exact_handoff_retryable_until_repaired under machine load and passed on 3 reruns and the next full run); the allowance test passed 40 of 40 consecutive runs on e29edd1 (it failed about 1 run in 8 before; the cause is in the earlier PR comment)
  • Not run: the 2 ignored live-adapter e2e tests, which need external Pubky and Electrum infrastructure.

Pin paykit-lib and paykit-sdk to the merge commit of the pubky/paykit-rs
Allowances stack (#158 to #161, 6561359) and port the Payment Request terms
to its validated builder and accessors.

The new StorageState starts with allowance_accounting. The server decodes the
rows it wrote with rc48 as version 1 (rc48 and rc55 share that layout) and
keeps writing version 1 while accounting is empty, so the rc48 build can still
read them.
During every handoff, after the link is ready, the server reads the reader's
private messages on that link and accepts each Allowance proposal that names
the creator as Allowee, up to four per handoff. The acceptance is queued ahead
of the endpoint list and the Payment Request, so the reader's wallet can pay
the request under the Allowance without a swipe. Intake is best effort: a
failure is logged (stage=allowance_intake) and the request is still proposed.

Only the server holds the creator's Noise key for its receiver path, so an
Allowance a reader grants on that link can be answered only here.
@ovitrif ovitrif self-assigned this Sep 30, 2026
@ovitrif
ovitrif changed the base branch from master to codex/paykit-shared-runtime-local-20260930 October 1, 2026 17:22
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-shared-runtime-local-20260930 branch from ab0e8b1 to af0151a Compare October 1, 2026 22:17
Base automatically changed from codex/paykit-shared-runtime-local-20260930 to master October 1, 2026 22:28
@ovitrif

ovitrif commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 748b125 (draft stays draft).

  • feat: use identity-wide shared paykit state #33 was rebased and then merged into master, so this PR now targets master. I merged feat: use identity-wide shared paykit state #33's final branch into this one (b259f8e, resolving the paykit.rs, workers/outbox.rs, tests and docs conflicts: the Allowance intake now runs after the recovery-marker observation and the link ensure) and then master (748b125, no tree change, since master has the same tree as feat: use identity-wide shared paykit state #33's final head).
  • Local checks on b259f8e: fmt, clippy -D warnings, cargo test -p paykit-server, companion-auth example and the PostgreSQL 16 e2e suite pass, with one exception: allowances.rs allowance_proposed_on_the_server_link_covers_the_next_locks_invoice fails intermittently on a duplicated payment request. It also fails about 1 run in 6 on b09f1b7, so it predates this merge. I did not change it here.
  • The description now drops the stack line and reports these checks.

@ovitrif

ovitrif commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed e29edd1: the e2e test no longer fails on redelivered requests. Test-only change in paykit-server-e2e/tests/allowances.rs; the server is unchanged.

Cause. I traced each handoff stage in the failing runs. The server does send the same Payment Request more than once, but only after a handoff outlived its outbox lease, never inside one:

  • This e2e set lease_duration = "5s" (production and the example config use 30s). A handoff here is a link handshake with the wallet, the allowance intake (a receive plus the accepts) and the send, over an in-process Pubky testnet. Under load it took 4.5 to 5.4 s.
  • Its fenced mark_handed_off was then rejected (transitioned=false) and the row was claimed again. The later claims also waited on the creator's mutation lock inside their own lease, so they expired as well and each queued the request again (3, 5 and 6 queued requests instead of 2).
  • That is the documented at-least-once behaviour in workers/outbox.rs. Every redelivery carried the same payment_reference, so a wallet sees one logical request.
  • At 30s the same machine still produced redeliveries in about 1 run in 30: one stalled run had a 19 s intake and a 7.8 s lock wait, and it passed only because the test read the queue before the second handoff finished. The raw count of queued requests was a race either way.

Fix (test only):

  • The e2e uses the production lease of 30s.
  • It asserts what the feature promises: the server's queued requests cover exactly the two Locks invoices, each invoice under one payment reference, and both acceptances precede the first request of the second invoice. Redelivered copies are tolerated.
  • post_locks_invoice retries a 503 up to 5 times. While validating this I hit one 503 creator_session_unavailable on POST /invoices (the creator session check treats ambiguous Pubky request failures as unavailable); the invoice call is an idempotent exact replay.

I left the product code alone. Nothing sends a duplicate within the lease, and the intake adds about a second to a handoff that normally finishes in a few seconds against a 30s lease. Counting lock wait inside the lease is a possible follow-up in the outbox, not part of this PR.

Runs (PostgreSQL 16 container, machine already under load):

  • Before: with the old assertion and a 5s lease, 3 of 25 runs failed (12%).
  • With the new assertion and the 5s lease, 29 of 30 passed; the one failure was the 503 above, which the retry now covers.
  • Final head, 30s lease: 40 of 40 passed.
  • cargo fmt --all --check, cargo clippy --locked --workspace --all-targets -- -D warnings, cargo test --locked -p paykit-server -- --test-threads=1, the companion-auth example tests and cargo test --locked -p paykit-server-e2e -- --test-threads=1: all pass.

@ovitrif

ovitrif commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 00c74b4: merged master (now containing #3's lock payment draining and #37) into this branch to clear the conflict in paykit.rs and workers/outbox.rs.

  • The Allowance intake now runs only for Payment Request proposals, not for the new cancellation handoffs.
  • tests/outbox.rs matches the HandoffResult::PaymentRequestProposal variant, and the allowance e2e signs POST /invoices with signature_preimage and expects 200 OK, following master's invoice contract.
  • Local checks: fmt, clippy -D warnings, cargo test -p paykit-server, companion-auth example and the PostgreSQL 16 e2e suite pass. One full e2e run failed once in server_workflow.rs malformed_recovery_marker_keeps_exact_handoff_retryable_until_repaired under load; it passed on 3 reruns and on the next full run.
  • The description drops the overlap section, since Feat/lock payment draining #3 is merged and logs: add safe correlation refs and prepare rc5 #28 is closed.

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.

2 participants