Skip to content

fix: adopt paykit 0.1.1-rc6 - #1803

Merged
ovitrif merged 14 commits into
masterfrom
chore/paykit-rc6
Nov 10, 2026
Merged

ovitrif merged 14 commits into
masterfrom
chore/paykit-rc6

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1842

Twin: synonymdev/bitkit-ios#1237

This PR:

  1. Updates Paykit to 0.1.1-rc6
  2. Measures Payment Proofs with the SDK before paying, for USDT and now also for Lightning and on-chain Payment Requests
  3. Explains a Payment Request note that is too long to send
  4. Explains a signup code the homeserver refuses, and network errors, during Pubky authorization
  5. Stops counting a USDT transfer made well before its Payment Request as paying it

Description

  • Pins com.synonym:paykit-android to 0.1.1-rc6, the same one-line change as earlier Paykit bumps. The bump itself only needed test fixtures for the new Payment Request record times and the private list acknowledgement field.
  • Uses paymentProofEventLen to measure the largest Payment Proof Bitkit would send, so the check matches what the SDK queues instead of a hand-built JSON copy. A proof that would not fit one Paykit message is refused before anything is paid, because it could never reach the payee. USDT already had this check. Lightning and on-chain Payment Request payments, manual and automatic, now have it too. An automatic Allowance payment measures before it accepts the request, so an oversized one stays manual instead of being accepted and then failing to pay.
  • Shows "Shorten the note and try again." when the SDK reports a one-time Payment Request as too large to send (a long note in a multi-byte script can do this). A subscription proposal the SDK finds too large shows the existing subscription message.
  • Shows "The homeserver did not accept this signup code. Try again or use another signup link." for a refused signup code, and "Check your connection and try again." for network errors, instead of "Unknown error" on the Pubky authorization sheet.
  • Flags a USDT receipt whose verified transfer time is more than 24 hours before the payee recorded the request (the Arbitrum timestamp bound) as "Received · Before request". It no longer counts as paying the request or a subscription period, as the Paykit payment time rule asks. The funds are still received.
  • Sign-up and sign-in already handle rc6's new error categories: network failures now arrive as transport errors, which session restore already treats as temporary, and an identity that lives on another homeserver already falls back to sign-in. Tests now pin both.
  • Notes for release: rc6 writes Paykit shared state and SDK backups in a newer format. Once an rc6 build writes them, builds on rc5 or older cannot read them, so Android and iOS should ship this together. rc6 also checks Encrypted Link write slots before publishing (one extra request per send) and has the Pubky HTTP client trust only the webpki-roots 1.0.11 list. The staging and production homeserver, relay, pkarr and homegate hosts all chain to ISRG Root X1, which that list includes.

Out of Scope

  • PaykitAllowanceRepo: accepting a contact's pending allowance offers with acceptPendingAllowanceProposals. The bulk call also accepts offers Bitkit does not list, such as non-BTC terms, and drops the per-proposal binding, so offers are still accepted one at a time. iOS made the same call.
  • Profile removal: original-grant observation and logout from Paykit rc3 and rc4 belong to fix: bind profile removal to its original owner #1693.
  • Contact removal: it still blocks the peer when withdrawing the private list reports a failure. Unchanged here.
  • Profile creation from the wallet seed: a refused Homegate signup code still falls back to sign-in, so the signup code message only shows in the signup link flow.
  • Subscription acceptance: the Acceptance is sent before any proof size check, so a third-party payee whose Payment Reference is too long for a proof only fails at the first payment. References Bitkit creates cannot hit this. iOS has the same gap.
  • Signup link flow: a sign-up refused because the identity lives on another homeserver still shows the generic authorization error. No new copy for it here.

Design

N/A - no design available.

Preview

N/A

QA Notes

Setup

  • Dev (regtest) build of this branch. It resolves com.synonym:paykit-android:0.1.1-rc6 from GitHub Packages (AAR SHA-256 792ace22d4e188733df3ec3644d69e22fb6692f39115ce55b706f91edc3ccdd8).
  • J1 uses two Bitkit instances on this build, saved as each other's contacts and linked as identities, as in request-summary.xml. Put the 256 "💜" note on the requester's clipboard, since adb shell input text cannot type emoji.
  • J2 needs a controlled requesting app that produces a pubkyauth://signup_grant URL whose signup token the homeserver refuses, for example the token of a link another wallet already completed, and a wallet with no Pubky identity. See the pubky-auth suite README.
  • Manual 1 needs the linked fixture issuer from the payment-requests suite, sending a one-time BTC request with a Payment Reference long enough that its Payment Proof would not fit 1000 bytes (about 700 bytes of reference).
  • Manual 2 needs the local USDT fixture from docs/usdt-qa.md and a payer that submits a Payment Proof for a USDT transfer made more than 24 hours before the request.

Journeys

  • J1: new request-note-too-long.xml - a note too long for one Paykit message is refused with "Shorten the note and try again." before anything is sent, and the request sends once the note is shortened
  • J2: new grant-signup-rejected-code.xml - a refused signup code shows its own message, keeps the sheet open and creates no identity

Manual Tests

  • 1. Fixture issuer sends a one-time BTC request with a near-limit Payment Reference → Pay → "Payment request is unavailable" shows and nothing is paid - an issuer request with a custom Payment Reference length not in Capabilities
  • 2. USDT payee receives a proof for a transfer made more than 24 hours before the request → Payment Requests → the row shows "Received · Before request" and the request is not shown as paid - a payer proving an older USDT transfer not in Capabilities
  • 3. Device offline → open a signup link → Authorize → "Check your connection and try again." shows - toggling device network not in Capabilities

Automated Checks

  • updated PaykitPaymentProofRepoTest.kt - a Lightning proof is measured with its largest preimage, app and allowance before payment, an on-chain proof too large for one message is refused before payment, and a proof the SDK cannot measure is refused as unavailable
  • updated PaykitAllowanceExecutorTest.kt - the proof is measured with the Allowance ID before automatic acceptance, and a request whose proof would not fit stays manual without acceptance
  • updated PaykitUsdtPaymentRepoTest.kt - the USDT proof is measured by the SDK with the largest valid receipt log index (fits and too large). Transfers within the 24 h tolerance pay the request and older ones are flagged and do not
  • updated PaykitPaymentStateBackupTest.kt - a USDT backup without the new flag still decodes, and the flag round trips
  • updated AppViewModelSendFlowTest.kt - a request too long to send shows "Shorten the note and try again." and does not complete
  • updated PaykitPaymentRequestRepoTest.kt - message_too_large maps to the note error, and other protocol errors pass through unchanged
  • updated PaykitPaymentRequestRepoSubscriptionTest.kt - an SDK message_too_large maps to the subscription message
  • updated PubkyAuthErrorMessageTest.kt - refused signup codes and transport errors get their own text, and other identity errors keep the fallback
  • updated PubkyRepoTest.kt - a sign-up refused because the identity lives elsewhere signs in, and a transport failure does not sign up again
  • updated PaykitIssuerInteropTest.kt, PrivatePaykitRepoTest.kt, PaykitSdkServiceTest.kt - fixtures for the new rc6 record and report fields
  • removed PaykitUsdtPaymentRepoTest.kt boundary cases for the hand-built proof JSON - the SDK now does the measurement
  • ran openssl s_client -showcerts against httprelay, homeserver, pkarr and homegate (production and staging), pkarr.pubky.org and relay.pkarr.org - every chain ends at ISRG Root X1

Evidence

  • 7cc585b5: just compile, just test and just lint pass locally (4,955 unit tests in 264 suites, 0 failures or skips, 0 detekt findings, 0 Android Lint issues).
  • The rc6 AAR resolved from GitHub Packages after the paykit-rs Gradle Package release run succeeded.

Gaps

  • J1, J2 and Manual 1 to 3 were not run by the author. Device and emulator runs were out of scope for this change. Reviewers run the device gates.
  • The shared journeys have the same <action> steps as the iOS twin (chore: adopt Paykit 0.1.1-rc6 bitkit-ios#1237), apart from platform identifiers.

Models used

  • Planning/scoping: Claude Opus 5.5 (reasoning: Not exposed)
  • Implementation: Claude Opus 5.5 (reasoning: Not exposed)
  • Review: Claude Opus 5.5 (reasoning: Not exposed)
  • Review rounds: 1

The session metadata does not expose a reasoning setting.

🤖 Generated with Claude Code

ben-kaufman and others added 13 commits November 6, 2026 11:42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 54b6ee4 (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@ben-kaufman
ben-kaufman marked this pull request as ready for review November 10, 2026 14:00
@greptile-apps

greptile-apps Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The reviewed changes appear safe to merge; no actionable defect was established.

Summary

Updates Paykit from 0.1.1-rc2 to 0.1.1-rc6.

  • Bitkit checks that payment proofs fit before it pays.
  • Old USDT transfers no longer count as paying a later request.
  • Oversized payment requests now tell people what to shorten.
  • Pubky authorization explains refused signup codes and network errors.

No actionable issues were established. Tests and device journeys were not run during this review.

Author ben-kaufman explicitly acknowledges these exclusions:

  • Older builds cannot read shared state or SDK backups written by rc6; Android and iOS should ship together.
  • Allowance offers remain individually accepted because the bulk call includes unsupported offers and loses per-proposal binding.
  • Original-grant observation and profile-removal logout belong to fix: bind profile removal to its original owner #1693.
  • Contact removal still blocks the peer after a failed private-list withdrawal; this is unchanged.
  • Wallet-seed signup still falls back to sign-in after a refused code; the new message targets signup links.
  • Subscription acceptance precedes proof sizing; oversized third-party references can fail at the first payment.
  • Signup links for identities on another homeserver retain the generic authorization error; new copy is out of scope.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Payment request] --> B[Measure largest payment proof]
    B --> C{Fits one message?}
    C -- No --> D[Stop before payment]
    C -- Yes --> E[Accept and pay]
    E --> F[Save and queue proof]
    F --> G[Payee verifies USDT transfer]
    G --> H{Transfer more than 24 hours before proposal?}
    H -- Yes --> I[Show received before request; keep unpaid]
    H -- No --> J[Apply amount and deadline checks]
Loading

Reviews (1) · Last reviewed commit: "fix: treat unmeasurable paykit proofs as..." · Reviewed by Greptile

@ovitrif

ovitrif commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

Triage: in 3.2.0. This PR closes #1842, filed in the 3.2.0 milestone at 👀 Review: besides the Paykit bump it changes payment and authorization behaviour that ships in 3.2.0, and the profile work builds on it. Twin: synonymdev/bitkit-ios#1237.

@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.

No findings. Bumps Paykit to 0.1.1-rc6, measures every Payment Proof with the SDK's paymentProofEventLen before paying (USDT, Lightning, on-chain, and automatic Allowance payments before acceptance), maps message_too_large and signup_code_rejected to their own copy, and flags USDT transfers more than 24h before the request as not paying it. Reviewed 7cc585b, full tier, statically (Bitkit Android branches are not built here).

What I checked, and 5 candidates I ruled out

Read in full (changed regions): PaykitPaymentProofRepo.kt (prepare, submit, proofJson, longestProofJson, requirePaymentProofFits), PaykitAllowanceExecutor.kt (automatic acceptance through payLightning), PaykitUsdtPaymentRepo.kt, PaykitPaymentRequestRepo.kt, PaykitSdkService.paymentProofEventLength, PubkyAuthErrorMessage.kt, PaykitExceptionExt.kt, the AppViewModel send-flow hunk.
Cross-checked against paykit-rs v0.1.1-rc6: payment_proof_event_len serializes through the same serialize_payment_request_event the queue uses, with a fresh v4 event id and request id, so a measurement is the real wire length; the FFI maps is_message_too_large to protocol code message_too_large; proposal_recorded_at is the payee's own receive or record time.
Call sites traced: prepare (manual Lightning and on-chain via AppViewModel.preparePaymentProof, Allowance via prepareProof), associateLightningPayment.
CI: build-local, lint, detekt, Greptile green; build-and-test, ui-tests and the e2e shards still pending at the time of this review.

Ruled out

  • Measured proof shorter than the one sent: longestProofJson() goes through the same proofJson() helper submit uses for Bitcoin kinds (64 hex chars, the fixed length of a preimage or txid), and USDT uses the max uint256 log index and full-length hash and signature, so the measurement is an upper bound.
  • Allowance measures different bindings than it later submits: the pre-acceptance check passes payment.endpoint.methodId.rawValue, payment.appId and candidate.allowanceId, the same values prepareProof and associateLightningPayment receive.
  • Old USDT backups failing to decode: beforeRequest defaults to false in both PaykitUsdtReceipt and the backup model; PaykitPaymentStateBackupTest.kt covers it.
  • An unmeasurable proof paying anyway: any FFI failure in paymentProofEventLength becomes RequestUnavailable before payment, and the Allowance path turns it into MANUAL before acceptance.
  • Transport mapping hiding a more specific auth error: localizedPubkyAuthMessage walks from the outermost throwable, so the specific PubkyAuthRequestError cases above Transport still win on a wrapped error.

Merge confidence: 4/5, payment and proof paths with unit coverage but build-and-test and the e2e shards had not finished, and J1, J2 and Manual 1 to 3 need a device.

Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the session does not report a reasoning setting)

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for 54b6ee4: Android 16, dev/regtest build with Paykit 0.1.1-rc6. All five cases passed. Videos run at 2× speed.

Shorten an oversized payment request note ✅ The long note was refused; shortening it to Coffee delivered the 1,000-sat request with the correct confirmation.

I pasted 256 purple hearts, observed “Shorten the note and try again.”, and checked that the payer had no new request. Coffee then sent successfully. I repeated delivery with a funded payer, checked the amount and note, and closed confirmation without paying.

Note rejection and delivery Funded payer confirmation

Requester on the left; payer on the right.

Reject an invalid signup code ✅ A refused signup token showed the specific homeserver message, kept authorization available, and created no identity.

I authorized a signup-grant link containing an invalid token against the staging homeserver. The recording shows the specific error toast. The app log reports Identity / signup_code_rejected; the wallet retained Profile onboarding.

Refused signup-code authorization.

Refuse a Bitcoin request whose proof cannot fit ✅ Attempting payment showed “Payment request is unavailable” and preserved the payer’s full balance.

A linked issuer with a debug-only hook sent a valid one-time proposal with a 594-byte reference. Paykit’s native measurement reported a 1,001-byte on-chain proof. On the unchanged payer, swiping to pay returned Home with the error and the full 100,000-sat balance; its log reports RequestUnavailable from proof preparation.

Payment refused before any Bitcoin was sent.

test hook

Not pushed. The issuer hook supplies a custom reference, removes optional proposal fields, selects one Bitcoin endpoint, and logs Paykit’s actual proof measurement. The receiving app used the unchanged reviewed head.

diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt
index 877c5a043..a7e65dadd 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt
@@ -955,11 +955,15 @@ class PaykitPaymentRequestRepo @Inject constructor(
             amountValue = draft.amount.value,
             amountAsset = draft.amount.asset.code,
             conversion = rates.takeIf { it.isNotEmpty() }?.let(PaymentConversion::Fixed),
-            paymentDeadline = PaymentDeadline.At(draft.expiresAt.toString()),
-            paymentReference = "bitkit-${UUID.randomUUID()}",
+            paymentDeadline = if (to.bitkit.BuildConfig.DEBUG && java.io.File(Env.logDir.parentFile, "v4-request-reference").exists()) null else PaymentDeadline.At(draft.expiresAt.toString()), // test hook
+            paymentReference = "bitkit-${UUID.randomUUID()}".let { original -> // test hook
+                if (to.bitkit.BuildConfig.DEBUG) { // test hook
+                    java.io.File(Env.logDir.parentFile, "v4-request-reference").takeIf { it.exists() }?.readText() ?: original // test hook
+                } else original // test hook
+            }, // test hook
             proposalExpiresAt = draft.expiresAt.toString(),
-            acceptedPaymentEndpointIdentifiers = endpointIdentifiers,
-            metadataJson = JsonObject(mapOf("note" to JsonPrimitive(draft.note))).toString(),
+            acceptedPaymentEndpointIdentifiers = if (to.bitkit.BuildConfig.DEBUG && java.io.File(Env.logDir.parentFile, "v4-request-reference").exists()) listOf("btc-regtest-p2wpkh") else endpointIdentifiers, // test hook
+            metadataJson = if (to.bitkit.BuildConfig.DEBUG && java.io.File(Env.logDir.parentFile, "v4-request-reference").exists()) "{}" else JsonObject(mapOf("note" to JsonPrimitive(draft.note))).toString(), // test hook
         )
     }
 
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index 1301df5fb..36ddb8bd5 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -1103,7 +1103,35 @@ class PaykitSdkService @Inject constructor(
         }
     }
 
-    suspend fun paymentRequests(): List<PaymentRequestRecord> = allPaymentRequests().filter(::isBitkitPaymentRequest)
+    suspend fun paymentRequests(): List<PaymentRequestRecord> { // test hook
+        val records = allPaymentRequests().filter(::isBitkitPaymentRequest) // test hook
+        if (to.bitkit.BuildConfig.DEBUG) { // test hook
+            val dump = org.json.JSONArray() // test hook
+            records.forEach { r -> // test hook
+                dump.put(org.json.JSONObject().put("id", r.paymentRequestId).put("peer", r.counterparty) // test hook
+                    .put("state", r.state.toString()).put("role", r.localRole.toString()) // test hook
+                    .put("reference", r.terms?.paymentReference?.exportText()).put("proposalRecordedAt", r.proposalRecordedAt) // test hook
+                    .put("terms", r.terms.toString()).put("proofCount", r.paymentProofs.size)) // test hook
+            } // test hook
+            java.io.File(context.filesDir, "v4-requests.json").writeText(dump.toString()) // test hook
+            val command = java.io.File(context.filesDir, "v4-proof-command.json") // test hook
+            val busy = java.io.File(context.filesDir, "v4-proof-command.busy") // test hook
+            if (command.exists() && command.renameTo(busy)) { // test hook
+                runSuspendCatching { // test hook
+                    val input = org.json.JSONObject(busy.readText()) // test hook
+                    val r = records.single { it.paymentRequestId == input.getString("id") } // test hook
+                    if (r.state == com.synonym.paykit.PaymentRequestLifecycleState.PROPOSED) { // test hook
+                        acceptPaymentRequest(r.counterparty, r.paymentRequestId, requireNotNull(r.proposalStreamItemId)) // test hook
+                    } // test hook
+                    submitPaymentProof(r.counterparty, r.paymentRequestId, "bitkit", "usdt-arbitrum-address", input.getJSONObject("proof").toString()) // test hook
+                    processPendingPrivateMessages() // test hook
+                    busy.renameTo(java.io.File(context.filesDir, "v4-proof-command.done")) // test hook
+                    Logger.info("Submitted controlled old-transfer proof for '${r.paymentRequestId}'", context = "TestHook") // test hook
+                }.onFailure { Logger.warn("Failed controlled proof", it, context = "TestHook") } // test hook
+            } // test hook
+        } // test hook
+        return records // test hook
+    } // test hook
 
     suspend fun readPaymentRequests(counterparty: String, expectedIdentity: String? = null): PaymentRequestRead {
         isSetup.await()
@@ -1220,7 +1248,7 @@ class PaykitSdkService @Inject constructor(
                 val terms = PaymentRequestTerms(
                     amount = PaymentRequestAmount(proposal.amountValue, proposal.amountAsset),
                     paymentReference = PaymentReference(proposal.paymentReference),
-                    proposalExpiresAt = proposal.proposalExpiresAt,
+                    proposalExpiresAt = if (to.bitkit.BuildConfig.DEBUG && java.io.File(context.filesDir, "v4-request-reference").exists()) null else proposal.proposalExpiresAt, // test hook
                     recurrence = proposal.recurrence?.let {
                         PaymentRequestRecurrence(
                             every = it.every,
@@ -1237,6 +1265,9 @@ class PaykitSdkService @Inject constructor(
                     paymentDeadline = proposal.paymentDeadline,
                     metadata = PrivateJsonObject(proposal.metadataJson),
                 )
+                if (to.bitkit.BuildConfig.DEBUG) { // test hook
+                    Logger.info("Fixture proposal refBytes='${proposal.paymentReference.toByteArray().size}' amount='${proposal.amountValue}' endpoints='${proposal.acceptedPaymentEndpointIdentifiers}' deadline='${terms.paymentDeadline}' expiry='${terms.proposalExpiresAt}' metadata='${terms.metadata.exportText()}' proofBytes='${paymentProofEventLength(proposal.paymentReference, "bitkit", "btc-regtest-p2wpkh", "{\"data\":\"${"0".repeat(64)}\",\"type\":\"bitcoin-onchain-txid\"}")}'", context = "TestHook") // test hook
+                } // test hook
                 completeSdkCall { handle.proposePaymentRequest(counterparty, terms) }
             }
         }
Fixture proposal refBytes='594' amount='0.00001' endpoints='[btc-regtest-p2wpkh]' deadline='null' expiry='null' metadata='{}' proofBytes='1001'
Failed to prepare a Paykit payment proof [RequestUnavailable='Payment request is unavailable']
Keep an old USDT transfer from paying a new request ✅ The list and detail showed “Received · Before request · 1.00 USDT” rather than Paid.

I sent a genuine 1 USDT transfer on the local fixture chain, dated more than 48 hours before the new $1 request. After restoring the fixture clock, the payer signed a proof bound to that request and submitted it through Paykit using a debug-only hook. The unchanged payee verified it and showed the “Before request” status in both views. The funds remained received.

Payment Requests Request detail

Old transfer shown without a Paid status.

test hook

Not pushed. The sender hook exports request bindings and submits a supplied signed proof through the real Paykit accept and submit operations. It does not alter proof verification or payment-time decisions. The receiving app used the unchanged reviewed head.

diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt
index 877c5a043..a7e65dadd 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt
@@ -955,11 +955,15 @@ class PaykitPaymentRequestRepo @Inject constructor(
             amountValue = draft.amount.value,
             amountAsset = draft.amount.asset.code,
             conversion = rates.takeIf { it.isNotEmpty() }?.let(PaymentConversion::Fixed),
-            paymentDeadline = PaymentDeadline.At(draft.expiresAt.toString()),
-            paymentReference = "bitkit-${UUID.randomUUID()}",
+            paymentDeadline = if (to.bitkit.BuildConfig.DEBUG && java.io.File(Env.logDir.parentFile, "v4-request-reference").exists()) null else PaymentDeadline.At(draft.expiresAt.toString()), // test hook
+            paymentReference = "bitkit-${UUID.randomUUID()}".let { original -> // test hook
+                if (to.bitkit.BuildConfig.DEBUG) { // test hook
+                    java.io.File(Env.logDir.parentFile, "v4-request-reference").takeIf { it.exists() }?.readText() ?: original // test hook
+                } else original // test hook
+            }, // test hook
             proposalExpiresAt = draft.expiresAt.toString(),
-            acceptedPaymentEndpointIdentifiers = endpointIdentifiers,
-            metadataJson = JsonObject(mapOf("note" to JsonPrimitive(draft.note))).toString(),
+            acceptedPaymentEndpointIdentifiers = if (to.bitkit.BuildConfig.DEBUG && java.io.File(Env.logDir.parentFile, "v4-request-reference").exists()) listOf("btc-regtest-p2wpkh") else endpointIdentifiers, // test hook
+            metadataJson = if (to.bitkit.BuildConfig.DEBUG && java.io.File(Env.logDir.parentFile, "v4-request-reference").exists()) "{}" else JsonObject(mapOf("note" to JsonPrimitive(draft.note))).toString(), // test hook
         )
     }
 
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index 1301df5fb..36ddb8bd5 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -1103,7 +1103,35 @@ class PaykitSdkService @Inject constructor(
         }
     }
 
-    suspend fun paymentRequests(): List<PaymentRequestRecord> = allPaymentRequests().filter(::isBitkitPaymentRequest)
+    suspend fun paymentRequests(): List<PaymentRequestRecord> { // test hook
+        val records = allPaymentRequests().filter(::isBitkitPaymentRequest) // test hook
+        if (to.bitkit.BuildConfig.DEBUG) { // test hook
+            val dump = org.json.JSONArray() // test hook
+            records.forEach { r -> // test hook
+                dump.put(org.json.JSONObject().put("id", r.paymentRequestId).put("peer", r.counterparty) // test hook
+                    .put("state", r.state.toString()).put("role", r.localRole.toString()) // test hook
+                    .put("reference", r.terms?.paymentReference?.exportText()).put("proposalRecordedAt", r.proposalRecordedAt) // test hook
+                    .put("terms", r.terms.toString()).put("proofCount", r.paymentProofs.size)) // test hook
+            } // test hook
+            java.io.File(context.filesDir, "v4-requests.json").writeText(dump.toString()) // test hook
+            val command = java.io.File(context.filesDir, "v4-proof-command.json") // test hook
+            val busy = java.io.File(context.filesDir, "v4-proof-command.busy") // test hook
+            if (command.exists() && command.renameTo(busy)) { // test hook
+                runSuspendCatching { // test hook
+                    val input = org.json.JSONObject(busy.readText()) // test hook
+                    val r = records.single { it.paymentRequestId == input.getString("id") } // test hook
+                    if (r.state == com.synonym.paykit.PaymentRequestLifecycleState.PROPOSED) { // test hook
+                        acceptPaymentRequest(r.counterparty, r.paymentRequestId, requireNotNull(r.proposalStreamItemId)) // test hook
+                    } // test hook
+                    submitPaymentProof(r.counterparty, r.paymentRequestId, "bitkit", "usdt-arbitrum-address", input.getJSONObject("proof").toString()) // test hook
+                    processPendingPrivateMessages() // test hook
+                    busy.renameTo(java.io.File(context.filesDir, "v4-proof-command.done")) // test hook
+                    Logger.info("Submitted controlled old-transfer proof for '${r.paymentRequestId}'", context = "TestHook") // test hook
+                }.onFailure { Logger.warn("Failed controlled proof", it, context = "TestHook") } // test hook
+            } // test hook
+        } // test hook
+        return records // test hook
+    } // test hook
 
     suspend fun readPaymentRequests(counterparty: String, expectedIdentity: String? = null): PaymentRequestRead {
         isSetup.await()
@@ -1220,7 +1248,7 @@ class PaykitSdkService @Inject constructor(
                 val terms = PaymentRequestTerms(
                     amount = PaymentRequestAmount(proposal.amountValue, proposal.amountAsset),
                     paymentReference = PaymentReference(proposal.paymentReference),
-                    proposalExpiresAt = proposal.proposalExpiresAt,
+                    proposalExpiresAt = if (to.bitkit.BuildConfig.DEBUG && java.io.File(context.filesDir, "v4-request-reference").exists()) null else proposal.proposalExpiresAt, // test hook
                     recurrence = proposal.recurrence?.let {
                         PaymentRequestRecurrence(
                             every = it.every,
@@ -1237,6 +1265,9 @@ class PaykitSdkService @Inject constructor(
                     paymentDeadline = proposal.paymentDeadline,
                     metadata = PrivateJsonObject(proposal.metadataJson),
                 )
+                if (to.bitkit.BuildConfig.DEBUG) { // test hook
+                    Logger.info("Fixture proposal refBytes='${proposal.paymentReference.toByteArray().size}' amount='${proposal.amountValue}' endpoints='${proposal.acceptedPaymentEndpointIdentifiers}' deadline='${terms.paymentDeadline}' expiry='${terms.proposalExpiresAt}' metadata='${terms.metadata.exportText()}' proofBytes='${paymentProofEventLength(proposal.paymentReference, "bitkit", "btc-regtest-p2wpkh", "{\"data\":\"${"0".repeat(64)}\",\"type\":\"bitcoin-onchain-txid\"}")}'", context = "TestHook") // test hook
+                } // test hook
                 completeSdkCall { handle.proposePaymentRequest(counterparty, terms) }
             }
         }
Transfer receipt timestamp: 2026-11-08T15:41:20Z
New proposal recorded by payer: 2026-11-10T15:59:30.281121955+00:00
Submitted controlled old-transfer proof for '8279aaf6-34af-4cd0-9313-378154c98c8c'
PaymentRequestDetailsStatus: Received · Before request · 1.00 USDT
Explain an offline signup authorization ✅ Authorize showed “Check your connection and try again.” and kept the sheet open.

I disabled Wi-Fi and mobile data before authorizing the signup link. The app log reports Transport / transport_error during homeserver resolution. Networking was restored afterward.

Offline authorization with the connection message and enabled controls.

Check: build and changed unit suites ✅ The head built and the selected repository, backup, authentication and send-flow suites passed.

I built the dev/debug app and ran the nine changed suite selectors: 1,235 tests, no failures, errors or skips. The resolved Paykit AAR matched the SHA-256 pinned in this PR.

I reviewed the full 1764309..54b6ee4 diff and its payment callers. Automatic Allowance preflight, Lightning proof sizing, backup compatibility and subscription error mapping were covered by code review and unit tests, without separate device cases. No planned device case was left unrun.

The Android behavior and journey actions match synonymdev/bitkit-ios#1237 fix: adopt paykit 0.1.1-rc6; I did not run the iOS app.

I also read Talos’s earlier review; its static review was context, and the device results above are from this head.

Models used: review and device validation — gpt-6.1-sol, reasoning high; independent diff review — gpt-6.1-sol, reasoning high.


Reviewed by gpt-6.1-sol-high

@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.

Code scan at this head found no HIGH, MEDIUM or LOW issues. The device gate still has to run; I'll post its result in a follow-up review.

What I checked in the rc2 → rc6 SDK changes:

  • New error codes: message_too_large and identity/signup_code_rejected are mapped to the new copy.
  • Sign-up/sign-in errors now Transport: these already go to the deferred-restore and temporary-error paths.
  • Proof size check: paymentProofEventLen runs before anything is claimed or persisted, for Lightning, on-chain, USDT and allowance requests. The placeholder proof matches the real proof's shape, so the measured size is at least the sent size.
  • USDT "before request":
    • proposalRecordedAt comes from the outbound message's created_at, which rc2 already stored. Receipts verified before the upgrade don't flip to "Before request".
    • beforeRequest defaults to false, so backups from rc2 builds still decode.
  • stateRevision busy error: it only fires under callback storage. Bitkit uses PubkyShared and already maps read failures to null.
  • State migration: the state blob goes from v1 to v4. A single-device upgrade from 3.1.0 reads v1 fine. As the PR body says, an rc2 build can't read state once an rc6 build has written it.

Out of scope: a user with a 3.1.0 device and a 3.2.0 device on the same Pubky identity loses Paykit on the old device until it updates. The PR accepts this ("ship together"). It would help to add a line about it to the release notes.

Reviewer model: claude-fable-5-1 (Stage 1), claude-opus-5-5 (coordination)
Reasoning effort: not exposed (Fable subagent), default (Opus)

@ovitrif ovitrif 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.

utAck

@ovitrif

ovitrif commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman merged, as it was approved and green.

@ovitrif
ovitrif merged commit 816f08e into master Nov 10, 2026
24 checks passed
@ovitrif
ovitrif deleted the chore/paykit-rc6 branch November 10, 2026 16:24
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.

fix: check payment proof size before paying

4 participants