payment-kit 1.27.2 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/__blocklet__.js +37 -0
  2. package/api/ocap-1.30-subpath-shims.d.ts +35 -0
  3. package/api/src/crons/index.ts +10 -0
  4. package/api/src/crons/metering-subscription-detection.ts +12 -14
  5. package/api/src/crons/overdue-detection.ts +51 -74
  6. package/api/src/integrations/arcblock/nft.ts +6 -2
  7. package/api/src/integrations/arcblock/stake.ts +3 -2
  8. package/api/src/integrations/arcblock/token.ts +4 -4
  9. package/api/src/integrations/blocklet/notification.ts +1 -1
  10. package/api/src/integrations/ethereum/tx.ts +29 -0
  11. package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
  12. package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
  13. package/api/src/integrations/stripe/resource.ts +8 -0
  14. package/api/src/libs/audit.ts +32 -16
  15. package/api/src/libs/auth.ts +49 -2
  16. package/api/src/libs/chain-error.ts +31 -0
  17. package/api/src/libs/error.ts +15 -0
  18. package/api/src/libs/event.ts +42 -1
  19. package/api/src/libs/invoice.ts +69 -34
  20. package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
  21. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
  22. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
  23. package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
  24. package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
  25. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
  26. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
  27. package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
  28. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
  29. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
  30. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
  31. package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
  32. package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
  33. package/api/src/libs/pagination.ts +14 -9
  34. package/api/src/libs/payment.ts +25 -10
  35. package/api/src/libs/session.ts +1 -1
  36. package/api/src/libs/timing.ts +35 -0
  37. package/api/src/libs/util.ts +16 -15
  38. package/api/src/libs/wallet-migration.ts +72 -53
  39. package/api/src/queues/auto-recharge.ts +1 -1
  40. package/api/src/queues/credit-consume.ts +94 -12
  41. package/api/src/queues/credit-grant.ts +4 -0
  42. package/api/src/queues/event.ts +14 -2
  43. package/api/src/queues/invoice.ts +1 -0
  44. package/api/src/queues/payment.ts +83 -15
  45. package/api/src/queues/refund.ts +84 -71
  46. package/api/src/queues/subscription.ts +1 -0
  47. package/api/src/routes/checkout-sessions.ts +82 -43
  48. package/api/src/routes/connect/change-payment.ts +2 -0
  49. package/api/src/routes/connect/change-plan.ts +2 -0
  50. package/api/src/routes/connect/pay.ts +12 -3
  51. package/api/src/routes/connect/setup.ts +3 -1
  52. package/api/src/routes/connect/shared.ts +52 -39
  53. package/api/src/routes/connect/subscribe.ts +4 -1
  54. package/api/src/routes/credit-grants.ts +25 -17
  55. package/api/src/routes/donations.ts +2 -2
  56. package/api/src/routes/meter-events.ts +16 -6
  57. package/api/src/routes/payment-links.ts +1 -1
  58. package/api/src/routes/payment-methods.ts +1 -1
  59. package/api/src/routes/settings.ts +1 -1
  60. package/api/src/routes/tax-rates.ts +1 -1
  61. package/api/src/store/models/customer.ts +23 -1
  62. package/api/src/store/models/payment-method.ts +4 -0
  63. package/api/src/store/models/price.ts +23 -14
  64. package/api/tests/libs/wallet-migration.spec.ts +4 -4
  65. package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
  66. package/api/tests/queues/credit-consume.spec.ts +8 -4
  67. package/api/tests/routes/credit-grants.spec.ts +1 -0
  68. package/blocklet.yml +1 -1
  69. package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
  70. package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
  71. package/cloudflare/README.md +499 -0
  72. package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
  73. package/cloudflare/build.ts +151 -0
  74. package/cloudflare/did-connect-auth.ts +527 -0
  75. package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
  76. package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
  77. package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
  78. package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
  79. package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
  80. package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
  81. package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
  82. package/cloudflare/frontend-shims/js-sdk.ts +43 -0
  83. package/cloudflare/frontend-shims/mime-types.ts +46 -0
  84. package/cloudflare/frontend-shims/session.ts +24 -0
  85. package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
  86. package/cloudflare/index.html +40 -0
  87. package/cloudflare/migrate-to-d1.js +252 -0
  88. package/cloudflare/migrations/0001_initial_schema.sql +82 -0
  89. package/cloudflare/migrations/0002_indexes.sql +75 -0
  90. package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
  91. package/cloudflare/run-build.js +390 -0
  92. package/cloudflare/scripts/test-decrypt.js +102 -0
  93. package/cloudflare/shims/arcblock-ws.ts +20 -0
  94. package/cloudflare/shims/axios-http-adapter.ts +4 -0
  95. package/cloudflare/shims/axios-lite.ts +117 -0
  96. package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
  97. package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
  98. package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
  99. package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
  100. package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
  101. package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
  102. package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
  103. package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
  104. package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
  105. package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
  106. package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
  107. package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
  108. package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
  109. package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
  110. package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
  111. package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
  112. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
  113. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
  114. package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
  115. package/cloudflare/shims/cookie-parser.ts +3 -0
  116. package/cloudflare/shims/cors.ts +21 -0
  117. package/cloudflare/shims/cron.ts +189 -0
  118. package/cloudflare/shims/crypto-js-warn.ts +7 -0
  119. package/cloudflare/shims/did-space-js.ts +17 -0
  120. package/cloudflare/shims/did-space.ts +11 -0
  121. package/cloudflare/shims/error.ts +18 -0
  122. package/cloudflare/shims/express-compat/index.ts +80 -0
  123. package/cloudflare/shims/express-compat/types.ts +41 -0
  124. package/cloudflare/shims/fastq.ts +105 -0
  125. package/cloudflare/shims/lock.ts +115 -0
  126. package/cloudflare/shims/mime-types.ts +56 -0
  127. package/cloudflare/shims/nedb-storage.ts +9 -0
  128. package/cloudflare/shims/node-child-process.ts +9 -0
  129. package/cloudflare/shims/node-fs.ts +20 -0
  130. package/cloudflare/shims/node-http.ts +13 -0
  131. package/cloudflare/shims/node-https.ts +4 -0
  132. package/cloudflare/shims/node-misc.ts +15 -0
  133. package/cloudflare/shims/node-net.ts +8 -0
  134. package/cloudflare/shims/node-os.ts +14 -0
  135. package/cloudflare/shims/node-tty.ts +8 -0
  136. package/cloudflare/shims/node-zlib.ts +17 -0
  137. package/cloudflare/shims/noop.ts +26 -0
  138. package/cloudflare/shims/payment-vendor.ts +14 -0
  139. package/cloudflare/shims/querystring.ts +12 -0
  140. package/cloudflare/shims/queue.ts +585 -0
  141. package/cloudflare/shims/rolldown-runtime.ts +43 -0
  142. package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
  143. package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
  144. package/cloudflare/shims/sequelize-d1/index.ts +34 -0
  145. package/cloudflare/shims/sequelize-d1/model.ts +1157 -0
  146. package/cloudflare/shims/sequelize-d1/operators.ts +293 -0
  147. package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
  148. package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
  149. package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
  150. package/cloudflare/shims/sequelize-d1/types.ts +35 -0
  151. package/cloudflare/shims/stripe-cf.ts +29 -0
  152. package/cloudflare/shims/ws-lite.ts +103 -0
  153. package/cloudflare/shims/xss.ts +3 -0
  154. package/cloudflare/tests/shims/cron.spec.ts +210 -0
  155. package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
  156. package/cloudflare/vite.config.ts +162 -0
  157. package/cloudflare/worker.ts +1553 -0
  158. package/cloudflare/wrangler.json +63 -0
  159. package/cloudflare/wrangler.jsonc +69 -0
  160. package/cloudflare/wrangler.staging.json +66 -0
  161. package/cloudflare/wrangler.toml +28 -0
  162. package/jest.config.js +4 -12
  163. package/package.json +26 -22
  164. package/src/app.tsx +62 -4
  165. package/src/components/customer/link.tsx +9 -13
  166. package/src/components/customer/notification-preference.tsx +3 -2
  167. package/src/components/filter-toolbar.tsx +4 -0
  168. package/src/components/invoice/list.tsx +9 -1
  169. package/src/components/invoice-pdf/utils.ts +2 -1
  170. package/src/components/layout/admin.tsx +39 -5
  171. package/src/components/layout/user-cf.tsx +77 -0
  172. package/src/components/payment-intent/actions.tsx +23 -3
  173. package/src/components/safe-did-address.tsx +75 -0
  174. package/src/libs/patch-user-card.ts +25 -0
  175. package/src/libs/util.ts +5 -7
  176. package/src/pages/admin/billing/meter-events/index.tsx +4 -0
  177. package/src/pages/admin/customers/customers/detail.tsx +2 -2
  178. package/src/pages/admin/customers/customers/index.tsx +2 -2
  179. package/src/pages/admin/overview.tsx +3 -1
  180. package/src/pages/customer/subscription/detail.tsx +4 -4
  181. package/tsconfig.api.json +1 -6
  182. package/tsconfig.json +3 -4
  183. package/tsconfig.types.json +2 -1
  184. package/vite.config.ts +6 -1
@@ -6,6 +6,7 @@ import { encodeTransferItx } from '../../integrations/ethereum/token';
6
6
  import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/ethereum/tx';
7
7
  import { CallbackArgs, ethWallet } from '../../libs/auth';
8
8
  import logger from '../../libs/logger';
9
+ import { parseChainError } from '../../libs/chain-error';
9
10
  import { getGasPayerExtra } from '../../libs/payment';
10
11
  import { createPaymentOutput } from '../../libs/session';
11
12
  import { getTxMetadata } from '../../libs/util';
@@ -130,6 +131,9 @@ export default {
130
131
  const client = paymentMethod.getOcapClient();
131
132
  const claim = claims.find((x) => x.type === 'prepareTx');
132
133
 
134
+ // Warm up chain context cache to avoid setTimeout(resolve,0) hang in workerd
135
+ await client.getContext();
136
+
133
137
  const tx: Partial<Transaction> = client.decodeTx(claim.finalTx);
134
138
  if (claim.delegator && claim.from) {
135
139
  tx.delegator = claim.delegator;
@@ -227,10 +231,15 @@ export default {
227
231
 
228
232
  return { hash: txHash };
229
233
  } catch (err) {
230
- console.error(err);
231
- logger.error('Failed to finalize paymentIntent on arcblock', { paymentIntent: paymentIntent.id, error: err });
234
+ const parsed = parseChainError(err);
235
+ logger.error('Failed to finalize paymentIntent on arcblock', {
236
+ paymentIntentId: paymentIntent.id,
237
+ code: parsed.code,
238
+ details: parsed.details,
239
+ raw: err,
240
+ });
232
241
  await paymentIntent.update({ status: 'requires_capture' });
233
- throw err;
242
+ throw parsed;
234
243
  }
235
244
  }
236
245
 
@@ -101,6 +101,8 @@ export default {
101
101
  billingThreshold: Math.max(minStakeAmount, billingThreshold),
102
102
  items,
103
103
  slippageConfig: subscription?.slippage_config || undefined,
104
+ // Reuse DelegateState fetched above — avoids a duplicate chain RPC on CF cold isolates
105
+ delegateState: delegation.state,
104
106
  }),
105
107
  });
106
108
  }
@@ -217,7 +219,7 @@ export default {
217
219
 
218
220
  await checkoutSession.update({ status: 'complete', payment_status: 'paid' });
219
221
  if (invoice) {
220
- invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
222
+ await invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
221
223
  }
222
224
  await addSubscriptionJob(subscription, 'cycle', false, subscription.trial_end);
223
225
  logger.info('CheckoutSession updated on setup done', {
@@ -1,12 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/indent */
2
2
  /* eslint-disable prettier/prettier */
3
3
  import { toTypeInfo } from '@arcblock/did';
4
- import { toDelegateAddress } from '@arcblock/did-util';
5
- import type { Transaction } from '@ocap/client';
4
+ import { toDelegateAddress } from '@arcblock/did-util/cbor';
5
+ import type { DelegateState as DelegateStateType, Transaction } from '@ocap/client';
6
6
  import { BN, fromTokenToUnit, toBase58 } from '@ocap/util';
7
7
  import { fromPublicKey } from '@ocap/wallet';
8
8
  import type { Request } from 'express';
9
- import { isEmpty } from 'lodash';
9
+ import isEmpty from 'lodash/isEmpty';
10
10
  import dayjs from '../../libs/dayjs';
11
11
  import { estimateMaxGasForTx, hasStakedForGas } from '../../integrations/arcblock/stake';
12
12
  import { encodeApproveItx } from '../../integrations/ethereum/token';
@@ -31,6 +31,7 @@ import {
31
31
  } from '../../libs/subscription';
32
32
  import { getCustomerStakeAddress, OCAP_PAYMENT_TX_TYPE } from '../../libs/util';
33
33
 
34
+ // eslint-disable-next-line import/no-cycle
34
35
  import { invoiceQueue } from '../../queues/invoice';
35
36
  import { AutoRechargeConfig, type TLineItemExpanded } from '../../store/models';
36
37
  import { CheckoutSession } from '../../store/models/checkout-session';
@@ -453,8 +454,6 @@ export async function ensureInvoiceForCheckout({
453
454
  }
454
455
  }
455
456
 
456
- const currency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
457
-
458
457
  const metadata = {
459
458
  ...(checkoutSession.invoice_creation?.invoice_data?.metadata || {}),
460
459
  };
@@ -467,7 +466,13 @@ export async function ensureInvoiceForCheckout({
467
466
  const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
468
467
  const trialEnd = Number(checkoutSession.subscription_data?.trial_end || 0);
469
468
  const now = dayjs().unix();
470
- let invoiceItems = lineItems || (await Price.expand(checkoutSession.line_items, { product: true, upsell: true }));
469
+
470
+ // Parallel: fetch currency + expand line items (independent queries)
471
+ const [currency, expandedItems] = await Promise.all([
472
+ PaymentCurrency.findByPk(checkoutSession.currency_id),
473
+ lineItems ? Promise.resolve(lineItems) : Price.expand(checkoutSession.line_items, { product: true, upsell: true }),
474
+ ]);
475
+ let invoiceItems = expandedItems;
471
476
 
472
477
  // For items with quote_id, fetch full quote info and attach to line item metadata
473
478
  // This ensures invoice line items have complete quote info for display
@@ -610,33 +615,21 @@ export async function ensureInvoiceForCheckout({
610
615
  discountCount: discountInfo.appliedDiscounts.length,
611
616
  });
612
617
 
613
- // only update invoice_id for single invoice
614
- if (!isGroupInvoice) {
615
- await checkoutSession.update({ invoice_id: invoice.id });
616
- }
617
- if (paymentIntent) {
618
- await paymentIntent.update({ invoice_id: invoice.id });
619
- }
620
- if (subscription) {
621
- await subscription.update({ latest_invoice_id: invoice.id });
622
- }
623
-
624
- // Update quotes with invoice_id for audit trail
625
- // Find quote_ids from invoice items and update the quotes
618
+ // Update invoice_id references in parallel — these are independent writes
626
619
  const quoteIdsFromItems = items
627
620
  .map((item) => item.metadata?.quote_id)
628
621
  .filter((id): id is string => typeof id === 'string' && id.length > 0);
629
622
 
630
- if (quoteIdsFromItems.length > 0) {
631
- await PriceQuote.update(
632
- { invoice_id: invoice.id },
633
- { where: { id: quoteIdsFromItems } }
634
- );
635
- logger.info('Updated quotes with invoice_id', {
636
- invoiceId: invoice.id,
637
- quoteIds: quoteIdsFromItems,
638
- });
639
- }
623
+ await Promise.all([
624
+ !isGroupInvoice ? checkoutSession.update({ invoice_id: invoice.id }) : Promise.resolve(),
625
+ paymentIntent ? paymentIntent.update({ invoice_id: invoice.id }) : Promise.resolve(),
626
+ subscription ? subscription.update({ latest_invoice_id: invoice.id }) : Promise.resolve(),
627
+ quoteIdsFromItems.length > 0
628
+ ? PriceQuote.update({ invoice_id: invoice.id }, { where: { id: quoteIdsFromItems } }).then(() => {
629
+ logger.info('Updated quotes with invoice_id', { invoiceId: invoice.id, quoteIds: quoteIdsFromItems });
630
+ })
631
+ : Promise.resolve(),
632
+ ]);
640
633
 
641
634
  return { invoice, items };
642
635
  }
@@ -861,6 +854,7 @@ export async function getDelegationTxClaim({
861
854
  billingThreshold = 0,
862
855
  requiredStake = true,
863
856
  slippageConfig,
857
+ delegateState,
864
858
  }: {
865
859
  userDid: string;
866
860
  userPk: string;
@@ -880,6 +874,10 @@ export async function getDelegationTxClaim({
880
874
  min_acceptable_rate?: string;
881
875
  base_currency?: string;
882
876
  };
877
+ // Optional DelegateState passthrough — avoids a duplicate chain RPC in getTokenLimitsForDelegation
878
+ // when the caller already fetched it (e.g. via isDelegationSufficientForPayment). Same-request
879
+ // usage only — do not cache across requests (see delegationCache note in libs/payment.ts).
880
+ delegateState?: DelegateStateType | null;
883
881
  }) {
884
882
  // Calculate authorization amount
885
883
  // When slippage_config with min_acceptable_rate is provided, use it for precise calculation
@@ -902,16 +900,23 @@ export async function getDelegationTxClaim({
902
900
  }
903
901
 
904
902
  const address = toDelegateAddress(userDid, wallet.address);
905
- const tokenLimits = await getTokenLimitsForDelegation(items, paymentMethod, paymentCurrency, address, amount);
906
- let tokenRequirements = await getTokenRequirements({
907
- items,
908
- mode,
909
- trialing,
910
- billingThreshold,
911
- paymentMethod,
912
- paymentCurrency,
913
- requiredStake,
914
- });
903
+ // These two helpers each issue 1–2 chain RPCs and don't depend on each other,
904
+ // so fan them out — on CF Workers cold isolates the serial path easily pushed the
905
+ // request over the runtime CPU/real-time budget and tripped a "hung promise" 500
906
+ // (see #1351). Parallelising halves the wall-clock for the delegation tx claim.
907
+ const [tokenLimits, tokenRequirementsResult] = await Promise.all([
908
+ getTokenLimitsForDelegation(items, paymentMethod, paymentCurrency, address, amount, delegateState),
909
+ getTokenRequirements({
910
+ items,
911
+ mode,
912
+ trialing,
913
+ billingThreshold,
914
+ paymentMethod,
915
+ paymentCurrency,
916
+ requiredStake,
917
+ }),
918
+ ]);
919
+ let tokenRequirements = tokenRequirementsResult;
915
920
 
916
921
  if (mode === 'delegation') {
917
922
  tokenRequirements = [];
@@ -1049,6 +1054,9 @@ export async function getStakeTxClaim({
1049
1054
  if (paymentMethod.type === 'arcblock') {
1050
1055
  // create staking data
1051
1056
  const client = paymentMethod.getOcapClient();
1057
+ // CF Workers: warm up chain context to avoid setTimeout(resolve,0) hang on cold client.
1058
+ // Same pattern as pay.ts; needed because did-connect-js tryWithTimeout bounds this callback at 10s.
1059
+ await client.getContext();
1052
1060
 
1053
1061
  const stakeId = hasGrouping ? `stake-group-${subscription.id}` : subscription.id;
1054
1062
 
@@ -1155,6 +1163,8 @@ export async function getOverdraftProtectionStakeTxClaim({
1155
1163
  if (paymentMethod.type === 'arcblock') {
1156
1164
  // create staking data
1157
1165
  const client = paymentMethod.getOcapClient();
1166
+ // CF Workers: warm up chain context to avoid setTimeout(resolve,0) hang on cold client.
1167
+ await client.getContext();
1158
1168
  const nonce = `overdraft-protection-${subscription.id}`;
1159
1169
  const address = await getCustomerStakeAddress(userDid, nonce);
1160
1170
  const { state } = await client.getStakeState({ address });
@@ -1579,6 +1589,9 @@ export async function executeOcapTransactions(
1579
1589
  nonce?: string
1580
1590
  ) {
1581
1591
  const client = paymentMethod.getOcapClient();
1592
+ // CF Workers: warm up chain context to avoid setTimeout(resolve,0) hang on cold client.
1593
+ // Each subsequent executeSingleTransaction (Delegate + Stake broadcast) needs ready context.
1594
+ await client.getContext();
1582
1595
  logger.info('start executeOcapTransactions', { userDid, claims });
1583
1596
 
1584
1597
  const delegation = claims.find((x) => x.type === 'signature' && x.meta?.purpose === 'delegation');
@@ -133,6 +133,8 @@ export default {
133
133
  items,
134
134
  requiredStake,
135
135
  slippageConfig: primarySubscription?.slippage_config || undefined,
136
+ // Reuse DelegateState fetched above — avoids a duplicate chain RPC on CF cold isolates
137
+ delegateState: delegation.state,
136
138
  }),
137
139
  });
138
140
  }
@@ -243,7 +245,8 @@ export default {
243
245
 
244
246
  for (const invoice of invoices) {
245
247
  if (invoice) {
246
- invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
248
+ // eslint-disable-next-line no-await-in-loop
249
+ await invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
247
250
  }
248
251
  }
249
252
 
@@ -27,6 +27,7 @@ import { blocklet } from '../libs/auth';
27
27
  import { formatMetadata } from '../libs/util';
28
28
  import { getPriceUintAmountByCurrency } from '../libs/price';
29
29
  import { checkTokenBalance } from '../libs/payment';
30
+ import { measurePhase } from '../libs/timing';
30
31
  import { getExchangeRateService } from '../libs/exchange-rate/service';
31
32
  import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
32
33
  import { isRateBelowMinAcceptableRate } from '../libs/slippage';
@@ -290,16 +291,19 @@ router.get('/verify-availability', authMine, async (req, res) => {
290
291
  const { customer_id: customerId, currency_id: currencyId } = value;
291
292
  let pendingAmount = value.pending_amount;
292
293
 
293
- const customer = await Customer.findByPkOrDid(customerId);
294
+ // Parallelize Customer + PaymentCurrency lookups (independent, saves ~85ms RTT)
295
+ const [customer, currency] = await Promise.all([
296
+ Customer.findByPkOrDid(customerId),
297
+ PaymentCurrency.findByPk(currencyId),
298
+ ]);
294
299
  if (!customer) {
295
300
  return res.status(404).json({ error: `Customer ${customerId} not found` });
296
301
  }
297
-
298
- const currency = await PaymentCurrency.findByPk(currencyId);
299
302
  if (!currency) {
300
303
  return res.status(404).json({ error: `PaymentCurrency ${currencyId} not found` });
301
304
  }
302
305
 
306
+ // AutoRechargeConfig needs customer.id which we now have
303
307
  const config = (await AutoRechargeConfig.findOne({
304
308
  where: {
305
309
  customer_id: customer.id,
@@ -511,13 +515,15 @@ router.get('/verify-availability', authMine, async (req, res) => {
511
515
  // This ensures user can pay off pending amount and still have balance for continued usage
512
516
  // minimumRequiredBalance = requiredPaymentAmount (to pay off pending) + totalAmount (for one more recharge)
513
517
  const minimumRequiredBalance = requiredPaymentAmount.add(totalAmount);
514
- balanceResult = await checkTokenBalance({
515
- paymentMethod: config.paymentMethod,
516
- paymentCurrency: config.rechargeCurrency,
517
- userDid: payer,
518
- amount: minimumRequiredBalance.toString(),
519
- skipUserCheck: true,
520
- });
518
+ balanceResult = await measurePhase('chain', () =>
519
+ checkTokenBalance({
520
+ paymentMethod: config.paymentMethod!,
521
+ paymentCurrency: config.rechargeCurrency!,
522
+ userDid: payer,
523
+ amount: minimumRequiredBalance.toString(),
524
+ skipUserCheck: true,
525
+ })
526
+ );
521
527
 
522
528
  if (!balanceResult.sufficient) {
523
529
  return res.json({
@@ -531,13 +537,15 @@ router.get('/verify-availability', authMine, async (req, res) => {
531
537
  }
532
538
  } else {
533
539
  // No pending amount: check if balance can cover at least one recharge
534
- balanceResult = await checkTokenBalance({
535
- paymentMethod: config.paymentMethod,
536
- paymentCurrency: config.rechargeCurrency,
537
- userDid: payer,
538
- amount: totalAmount.toString(),
539
- skipUserCheck: true,
540
- });
540
+ balanceResult = await measurePhase('chain', () =>
541
+ checkTokenBalance({
542
+ paymentMethod: config.paymentMethod!,
543
+ paymentCurrency: config.rechargeCurrency!,
544
+ userDid: payer,
545
+ amount: totalAmount.toString(),
546
+ skipUserCheck: true,
547
+ })
548
+ );
541
549
 
542
550
  if (!balanceResult.sufficient) {
543
551
  return res.json({
@@ -1,4 +1,4 @@
1
- import { Joi } from '@arcblock/validator';
1
+ import Joi from 'joi';
2
2
  import { Router } from 'express';
3
3
 
4
4
  import { BN } from '@ocap/util';
@@ -24,7 +24,7 @@ const donationSchema = Joi.object<DonationSettings>({
24
24
  beneficiaries: Joi.array()
25
25
  .items(
26
26
  Joi.object({
27
- address: Joi.DID().required(),
27
+ address: Joi.string().required(),
28
28
  share: Joi.number().positive().required(),
29
29
  memo: Joi.string().max(64).optional(),
30
30
  })
@@ -414,12 +414,22 @@ router.get('/overdue-summary', auth, async (req, res) => {
414
414
  currencyIds.length > 0 ? await PaymentCurrency.findAll({ where: { id: { [Op.in]: currencyIds } } }) : [];
415
415
  const currencyMap = new Map(currencies.map((c) => [c.id, c]));
416
416
 
417
- const list = results.map((r) => ({
418
- currency: currencyMap.get(r.currency_id),
419
- total_pending: r.total_pending,
420
- customer_count: r.customer_count,
421
- event_count: r.event_count,
422
- }));
417
+ // Filter out results whose currency no longer exists (e.g. deleted currency)
418
+ const list = results
419
+ .map((r) => {
420
+ const currency = currencyMap.get(r.currency_id);
421
+ if (!currency) {
422
+ logger.warn('overdue-summary: currency not found, skipping row', { currency_id: r.currency_id });
423
+ return null;
424
+ }
425
+ return {
426
+ currency,
427
+ total_pending: r.total_pending,
428
+ customer_count: r.customer_count,
429
+ event_count: r.event_count,
430
+ };
431
+ })
432
+ .filter((item): item is NonNullable<typeof item> => item !== null);
423
433
 
424
434
  return res.json({ list });
425
435
  } catch (err) {
@@ -1,4 +1,4 @@
1
- import { Joi } from '@arcblock/validator';
1
+ import Joi from 'joi';
2
2
  import { Router } from 'express';
3
3
  import pick from 'lodash/pick';
4
4
  import { Op } from 'sequelize';
@@ -6,7 +6,7 @@ import pick from 'lodash/pick';
6
6
  import { InferAttributes, Op, WhereOptions } from 'sequelize';
7
7
  import cloneDeep from 'lodash/cloneDeep';
8
8
  import merge from 'lodash/merge';
9
- import { Joi } from '@arcblock/validator';
9
+ import Joi from 'joi';
10
10
  import { ensureWebhookRegistered, cleanupStripeWebhook, validateStripeKeys } from '../integrations/stripe/setup';
11
11
  import logger from '../libs/logger';
12
12
  import { authenticate } from '../libs/security';
@@ -3,7 +3,7 @@ import pick from 'lodash/pick';
3
3
  import { Op, type WhereOptions } from 'sequelize';
4
4
 
5
5
  import Joi from 'joi';
6
- import { merge } from 'lodash';
6
+ import merge from 'lodash/merge';
7
7
  import { PaymentCurrency } from '../store/models/payment-currency';
8
8
  import { PaymentMethod } from '../store/models/payment-method';
9
9
  import { authenticate } from '../libs/security';
@@ -77,7 +77,7 @@ router.get('/', auth, async (req, res) => {
77
77
  literal(`(
78
78
  SELECT COUNT(DISTINCT ii.invoice_id)
79
79
  FROM invoice_items AS ii
80
- WHERE ii.tax_rate_id = TaxRate.id
80
+ WHERE ii.tax_rate_id = tax_rates.id
81
81
  )`),
82
82
  'invoice_count',
83
83
  ],
@@ -13,9 +13,9 @@ import {
13
13
  } from 'sequelize';
14
14
 
15
15
  import merge from 'lodash/merge';
16
+ import { getLock } from '../../libs/lock';
16
17
  import { createEvent } from '../../libs/audit';
17
18
  import CustomError from '../../libs/error';
18
- import { getLock } from '../../libs/lock';
19
19
  import { createCodeGenerator, createIdGenerator } from '../../libs/util';
20
20
  import type { CustomerAddress, CustomerPreferences, CustomerShipping } from './types';
21
21
 
@@ -156,6 +156,28 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
156
156
  };
157
157
 
158
158
  public async getInvoiceNumber() {
159
+ // Optimized path for CF Workers: atomic UPDATE...RETURNING in 1 D1 round trip.
160
+ // Original lock-based approach: lock.acquire → reload → increment(UPDATE + reload) → release
161
+ // = 2 D1 round trips for lock + 3 D1 round trips for data = ~1000ms at ~350ms/RT
162
+ // Optimized: single UPDATE...RETURNING = 1 D1 round trip = ~350ms
163
+ //
164
+ // SQLite serializes writes at the DB level, so the application-level lock is
165
+ // redundant for both D1 and Blocklet Server's SQLite.
166
+ const d1 = (globalThis as any).__CF_ENV__?.DB;
167
+ if (d1) {
168
+ try {
169
+ const sql =
170
+ // eslint-disable-next-line @typescript-eslint/quotes
171
+ 'UPDATE "customers" SET "next_invoice_sequence" = COALESCE("next_invoice_sequence", 1) + 1 WHERE "id" = ? RETURNING "next_invoice_sequence" - 1 as "prev_sequence"';
172
+ const result = await d1.prepare(sql).bind(this.id).first();
173
+ const sequence = (result as any)?.prev_sequence ?? 1;
174
+ return `${this.invoice_prefix}-${padStart(sequence.toString(), 4, '0')}`;
175
+ } catch (err) {
176
+ console.warn('[getInvoiceNumber] D1 RETURNING failed, falling back:', (err as any)?.message);
177
+ }
178
+ }
179
+
180
+ // Blocklet Server path (or D1 fallback): use lock-based approach
159
181
  const lock = getLock(`${this.id}-invoice-number`);
160
182
  await lock.acquire();
161
183
  await this.reload();
@@ -207,6 +207,10 @@ export class PaymentMethod extends Model<InferAttributes<PaymentMethod>, InferCr
207
207
  const host = this.settings.arcblock?.api_host;
208
208
  const cached = ocapClients.has(host);
209
209
  if (!cached) {
210
+ // 1.30.9: the default `@ocap/client` entry went CBOR-only, but current ABT
211
+ // wallets only decode protobuf-encoded tx bytes. Use `@ocap/client/legacy`
212
+ // (protobuf client) so encode / decode / broadcast all stay protobuf,
213
+ // matching the wallet signatures and what the chain node expects to hash.
210
214
  const created = new OcapClient(host);
211
215
  ocapClients.set(host, created);
212
216
  return created;
@@ -438,23 +438,32 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
438
438
  }
439
439
  });
440
440
 
441
- // expand upsell
441
+ // expand upsell — batch load to avoid N+1 queries
442
442
  if (upsell) {
443
- await Promise.all(
444
- prices.map(async (x) => {
445
- if (x.upsell?.upsells_to_id) {
446
- const to = await Price.findByPk(x.upsell?.upsells_to_id);
447
- if (to) {
448
- // @ts-ignore
449
- x.upsell.upsells_to = to;
450
- }
451
- }
452
- if (x.recurring?.meter_id) {
443
+ const upsellToIds = [...new Set(prices.map((x) => x.upsell?.upsells_to_id).filter(Boolean))] as string[];
444
+ const meterIds = [...new Set(prices.map((x) => x.recurring?.meter_id).filter(Boolean))] as string[];
445
+
446
+ const [upsellPrices, meters] = await Promise.all([
447
+ upsellToIds.length > 0 ? Price.findAll({ where: { id: upsellToIds } }) : [],
448
+ meterIds.length > 0 ? Meter.findAll({ where: { id: meterIds } }) : [],
449
+ ]);
450
+
451
+ const upsellPriceMap = new Map(upsellPrices.map((p) => [p.id, p]));
452
+ const meterMap = new Map(meters.map((m) => [m.id, m]));
453
+
454
+ prices.forEach((x) => {
455
+ if (x.upsell?.upsells_to_id) {
456
+ const to = upsellPriceMap.get(x.upsell.upsells_to_id);
457
+ if (to) {
453
458
  // @ts-ignore
454
- x.meter = await Meter.findByPk(x.recurring?.meter_id);
459
+ x.upsell.upsells_to = to;
455
460
  }
456
- })
457
- );
461
+ }
462
+ if (x.recurring?.meter_id) {
463
+ // @ts-ignore
464
+ x.meter = meterMap.get(x.recurring.meter_id) ?? null;
465
+ }
466
+ });
458
467
  }
459
468
 
460
469
  // @ts-ignore
@@ -79,7 +79,7 @@ describe('wallet-migration', () => {
79
79
 
80
80
  it('should return stored address if available and valid', async () => {
81
81
  const mockClient = createMockClient({
82
- getDelegateState: jest.fn().mockImplementation(({ address }) => {
82
+ getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
83
83
  if (address === 'stored_delegation_address') {
84
84
  return { state: { ops: [{ key: 'fg:x:transfer' }] } };
85
85
  }
@@ -105,7 +105,7 @@ describe('wallet-migration', () => {
105
105
  it('should fallback if stored address has no valid state', async () => {
106
106
  const oldAddress = toDelegateAddress(delegator, oldAppDid);
107
107
  const mockClient = createMockClient({
108
- getDelegateState: jest.fn().mockImplementation(({ address }) => {
108
+ getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
109
109
  // stored address has no state, but migratedFrom address has
110
110
  if (address === oldAddress) {
111
111
  return { state: { ops: [{ key: 'fg:x:transfer' }] } };
@@ -131,7 +131,7 @@ describe('wallet-migration', () => {
131
131
  it('should return current address if delegation exists on current wallet', async () => {
132
132
  const currentAddress = toDelegateAddress(delegator, currentWalletAddress);
133
133
  const mockClient = createMockClient({
134
- getDelegateState: jest.fn().mockImplementation(({ address }) => {
134
+ getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
135
135
  if (address === currentAddress) {
136
136
  return { state: { ops: [{ key: 'fg:x:transfer' }] } };
137
137
  }
@@ -154,7 +154,7 @@ describe('wallet-migration', () => {
154
154
  it('should fallback to migratedFrom addresses', async () => {
155
155
  const oldAddress = toDelegateAddress(delegator, oldAppDid);
156
156
  const mockClient = createMockClient({
157
- getDelegateState: jest.fn().mockImplementation(({ address }) => {
157
+ getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
158
158
  if (address === oldAddress) {
159
159
  return { state: { ops: [{ key: 'fg:x:transfer' }] } };
160
160
  }
@@ -248,13 +248,16 @@ describe('credit-consume: handleBatchCreditConsumption', () => {
248
248
  expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
249
249
 
250
250
  // Event 2 should fail — only 20 remaining but needs 80
251
- // It should have partial consumption saved and be marked for retry
251
+ // Partial consumption is saved and event leaves the retry chain immediately
252
+ // (Insufficient balance is non-retryable per Plan 3, 2026-04-25 — retry
253
+ // would only re-trip the same shortage until external credit top-up).
252
254
  expect(event2.update).toHaveBeenCalledWith(
253
255
  expect.objectContaining({
254
256
  credit_pending: expect.any(String),
255
257
  })
256
258
  );
257
- expect(event2.markAsRequiresCapture).toHaveBeenCalled();
259
+ expect(event2.markAsRequiresAction).toHaveBeenCalled();
260
+ expect(event2.markAsRequiresCapture).not.toHaveBeenCalled();
258
261
 
259
262
  expect(getLock().release).toHaveBeenCalled();
260
263
  });
@@ -386,7 +386,11 @@ describe('credit-consume: handleCreditConsumption', () => {
386
386
  // ==========================================
387
387
  // Scenario 22/23: Retry scheduling + max retries
388
388
  // ==========================================
389
- it('scenario 22: schedules retry on partial failure', async () => {
389
+ it('scenario 22: insufficient balance is non-retryable (goes straight to requires_action)', async () => {
390
+ // Insufficient credit balance only resolves via external top-up; retrying
391
+ // before that is guaranteed to fail again. Per Plan 3 (2026-04-25), this
392
+ // is classified as non-retryable so the event leaves the retry chain
393
+ // immediately rather than spawning N more delayed queue messages.
390
394
  const event = makeMeterEvent({
391
395
  getValue: jest.fn().mockReturnValue('500'),
392
396
  attempt_count: 0,
@@ -397,10 +401,10 @@ describe('credit-consume: handleCreditConsumption', () => {
397
401
 
398
402
  setupBasicMocks(event, meter, customer, [grant]);
399
403
 
400
- await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow();
404
+ await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow(/Insufficient credit balance/);
401
405
 
402
- // Should schedule retry (attempt_count=0 < MAX_RETRY_COUNT=3)
403
- expect(event.markAsRequiresCapture).toHaveBeenCalled();
406
+ expect(event.markAsRequiresAction).toHaveBeenCalled();
407
+ expect(event.markAsRequiresCapture).not.toHaveBeenCalled();
404
408
  });
405
409
 
406
410
  it('scenario 23: marks as requires_action after max retries', async () => {
@@ -92,6 +92,7 @@ describe('GET /api/credit-grants/verify-availability', () => {
92
92
  it('should return 404 if customer not found', async () => {
93
93
  if (!routeHandler) return;
94
94
  jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue(null as any);
95
+ jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({ id: 'currency_123' } as any);
95
96
 
96
97
  await routeHandler(mockReq as Request, mockRes as Response);
97
98
 
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.27.2
17
+ version: 1.28.0
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist