payment-kit 1.27.1 → 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.
- package/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +10 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/integrations/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +32 -16
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +16 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +14 -2
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -2
- package/api/src/routes/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +1 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/models/customer.ts +23 -1
- package/api/src/store/models/payment-method.ts +4 -0
- package/api/src/store/models/price.ts +23 -14
- package/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
- package/cloudflare/run-build.js +390 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +585 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1157 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +293 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1553 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +69 -0
- package/cloudflare/wrangler.staging.json +66 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +26 -22
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- package/src/components/safe-did-address.tsx +75 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/customers/customers/detail.tsx +2 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- package/vite.config.ts +6 -1
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import isEmpty from 'lodash/isEmpty';
|
|
2
2
|
|
|
3
3
|
import { BN } from '@ocap/util';
|
|
4
|
-
import pAll from 'p-all';
|
|
5
4
|
import { ensureStakedForGas } from '../integrations/arcblock/stake';
|
|
6
5
|
import { transferErc20FromUser } from '../integrations/ethereum/token';
|
|
7
6
|
import { createEvent } from '../libs/audit';
|
|
@@ -47,6 +46,8 @@ import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../libs/constants
|
|
|
47
46
|
import { getCheckoutSessionSubscriptionIds, getSubscriptionCreateSetup, SlippageOptions } from '../libs/session';
|
|
48
47
|
import { syncStripeSubscriptionAfterRecovery } from '../integrations/stripe/handlers/subscription';
|
|
49
48
|
import { getLock } from '../libs/lock';
|
|
49
|
+
// eslint-disable-next-line import/no-cycle
|
|
50
|
+
import { ensureInvoiceForCheckout } from '../routes/connect/shared';
|
|
50
51
|
|
|
51
52
|
type PaymentJob = {
|
|
52
53
|
paymentIntentId: string;
|
|
@@ -57,7 +58,12 @@ type PaymentJob = {
|
|
|
57
58
|
};
|
|
58
59
|
|
|
59
60
|
type DepositVaultJob = {
|
|
60
|
-
|
|
61
|
+
// Single-currency payload kept for backward compatibility with any in-flight
|
|
62
|
+
// messages pushed by older workers. New pushes set `currencyIds` instead so one
|
|
63
|
+
// cron tick emits a single batched job regardless of how many currencies have
|
|
64
|
+
// vault enabled.
|
|
65
|
+
currencyId?: string;
|
|
66
|
+
currencyIds?: string[];
|
|
61
67
|
};
|
|
62
68
|
|
|
63
69
|
async function updateQuantitySold(checkoutSession: CheckoutSession) {
|
|
@@ -106,7 +112,23 @@ const handleDepositVault = async (paymentCurrencyId: string) => {
|
|
|
106
112
|
export const depositVaultQueue = createQueue<DepositVaultJob>({
|
|
107
113
|
name: 'deposit-vault',
|
|
108
114
|
onJob: async (job) => {
|
|
109
|
-
|
|
115
|
+
let ids: string[] = [];
|
|
116
|
+
if (job.currencyIds?.length) {
|
|
117
|
+
ids = job.currencyIds;
|
|
118
|
+
} else if (job.currencyId) {
|
|
119
|
+
ids = [job.currencyId];
|
|
120
|
+
}
|
|
121
|
+
for (const currencyId of ids) {
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line no-await-in-loop
|
|
124
|
+
await handleDepositVault(currencyId);
|
|
125
|
+
} catch (error: any) {
|
|
126
|
+
// Failure in one currency must not poison siblings — original design ran each
|
|
127
|
+
// on its own queue message so a 3-retry ceiling applied per-currency. Now we
|
|
128
|
+
// swallow+log to keep that isolation when sharing a single message.
|
|
129
|
+
logger.error('handleDepositVault failed', { currencyId, error: error?.message || error });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
110
132
|
},
|
|
111
133
|
options: {
|
|
112
134
|
concurrency: 1,
|
|
@@ -390,7 +412,38 @@ export const handlePaymentSucceed = async (
|
|
|
390
412
|
if (!invoice && !slashStake) {
|
|
391
413
|
const checkoutSession = await CheckoutSession.findOne({ where: { payment_intent_id: paymentIntent.id } });
|
|
392
414
|
if (checkoutSession) {
|
|
393
|
-
|
|
415
|
+
if (
|
|
416
|
+
(globalThis as any).__CF_ENV__ &&
|
|
417
|
+
checkoutSession.mode === 'payment' &&
|
|
418
|
+
checkoutSession.invoice_creation?.enabled &&
|
|
419
|
+
!checkoutSession.invoice_id
|
|
420
|
+
) {
|
|
421
|
+
const customer = await Customer.findByPk(checkoutSession.customer_id);
|
|
422
|
+
if (customer) {
|
|
423
|
+
try {
|
|
424
|
+
const result = await ensureInvoiceForCheckout({
|
|
425
|
+
checkoutSession,
|
|
426
|
+
paymentIntent,
|
|
427
|
+
customer,
|
|
428
|
+
props: {
|
|
429
|
+
status: 'paid',
|
|
430
|
+
amount_due: '0',
|
|
431
|
+
amount_paid: checkoutSession.amount_total,
|
|
432
|
+
amount_remaining: '0',
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
if (result?.invoice) {
|
|
436
|
+
invoice = result.invoice;
|
|
437
|
+
}
|
|
438
|
+
} catch (err: any) {
|
|
439
|
+
logger.error('Failed to create invoice for checkout session', {
|
|
440
|
+
checkoutSessionId: checkoutSession.id,
|
|
441
|
+
error: err?.message,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
await updateCheckoutSessionOnPaymentSuccess(paymentIntent, checkoutSession, invoice || undefined);
|
|
394
447
|
}
|
|
395
448
|
return;
|
|
396
449
|
}
|
|
@@ -407,6 +460,10 @@ export const handlePaymentSucceed = async (
|
|
|
407
460
|
status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
|
|
408
461
|
});
|
|
409
462
|
logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`);
|
|
463
|
+
|
|
464
|
+
// Credit grant processing is triggered by the invoice.paid event listener
|
|
465
|
+
// (via createEvent in the afterUpdate hook). createEvent self-registers in
|
|
466
|
+
// __cfPendingJobs__ so it completes in CF Workers before the request ends.
|
|
410
467
|
}
|
|
411
468
|
|
|
412
469
|
// Mark quotes as paid after successful payment
|
|
@@ -1376,15 +1433,26 @@ export async function startDepositVaultQueue() {
|
|
|
1376
1433
|
const paymentCurrencies = (await PaymentCurrency.scope('withVaultConfig').findAll({
|
|
1377
1434
|
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
1378
1435
|
})) as (PaymentCurrency & { payment_method: PaymentMethod })[];
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1436
|
+
|
|
1437
|
+
const eligibleIds = paymentCurrencies
|
|
1438
|
+
.filter((x) => CHARGE_SUPPORTED_CHAIN_TYPES.includes(x.payment_method.type) && x.vault_config?.enabled === true)
|
|
1439
|
+
.map((x) => x.id);
|
|
1440
|
+
|
|
1441
|
+
if (eligibleIds.length === 0) {
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Single aggregated push — the consumer walks the id list internally with
|
|
1446
|
+
// per-currency error isolation. Reduces cron-driven Queue writes from N (one
|
|
1447
|
+
// per currency per 5-minute tick → ~288·N/day) to a constant 288/day (or
|
|
1448
|
+
// fewer when the previous batch hasn't cleared).
|
|
1449
|
+
//
|
|
1450
|
+
// Stable jobId preserves the backpressure the original per-currency IDs
|
|
1451
|
+
// provided: D1's unique constraint rejects a second push while the first
|
|
1452
|
+
// batch is still in D1 (in flight / being consumed). Worst case: next cron
|
|
1453
|
+
// skips one tick, events catch up on the following 5-minute cycle.
|
|
1454
|
+
depositVaultQueue.push({
|
|
1455
|
+
id: 'deposit-vault-batch',
|
|
1456
|
+
job: { currencyIds: eligibleIds },
|
|
1457
|
+
});
|
|
1390
1458
|
}
|
package/api/src/queues/refund.ts
CHANGED
|
@@ -141,7 +141,7 @@ export const handleRefund = async (job: RefundJob) => {
|
|
|
141
141
|
async function finalizeRefundAfterTransfer(refund: Refund, paymentDetails: any): Promise<void> {
|
|
142
142
|
try {
|
|
143
143
|
// Try to burn credit tokens if this refund is for a credit purchase
|
|
144
|
-
const totalBurned = await
|
|
144
|
+
const totalBurned = await handleCreditRefund(refund);
|
|
145
145
|
|
|
146
146
|
// All succeeded, update status
|
|
147
147
|
await refund.update({
|
|
@@ -506,31 +506,22 @@ events.on('refund.created', (refund: Refund) => {
|
|
|
506
506
|
});
|
|
507
507
|
|
|
508
508
|
/**
|
|
509
|
-
* Handle
|
|
509
|
+
* Handle credit refund - roll back credit grants when a credit purchase is refunded.
|
|
510
510
|
*
|
|
511
|
-
*
|
|
512
|
-
* 1.
|
|
513
|
-
* 2.
|
|
514
|
-
* 3.
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
* 6. Update credit grant status
|
|
511
|
+
* For each grant linked to the refunded invoice:
|
|
512
|
+
* 1. Compute the proportional amount to claw back (shouldBurn = grantTotal × refundAmount / invoiceTotal).
|
|
513
|
+
* 2. Clamp to remaining_amount (already-consumed credit stays in the system wallet).
|
|
514
|
+
* 3. If the grant is on-chain (chain_status === 'mint_completed'), burn user-held tokens first.
|
|
515
|
+
* Off-chain grants skip the burn — the refund has already moved fiat/credit back to the user.
|
|
516
|
+
* 4. Update remaining_amount / status / chain_detail identically in both paths so the
|
|
517
|
+
* customer.credit_grant.voided event fires when a grant is fully rolled back.
|
|
519
518
|
*
|
|
520
519
|
* @param refund - The refund record
|
|
521
|
-
* @
|
|
522
|
-
* @
|
|
523
|
-
* @throws Error if burn operation fails
|
|
520
|
+
* @returns Total amount clawed back from user (as string), undefined if nothing to process
|
|
521
|
+
* @throws Error if any on-chain burn fails
|
|
524
522
|
*/
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
*
|
|
528
|
-
* @param refund - The refund record
|
|
529
|
-
* @returns Total amount burned (as string), undefined if nothing to burn
|
|
530
|
-
* @throws Error if burn operation fails
|
|
531
|
-
*/
|
|
532
|
-
export async function handleOnchainCreditRefund(refund: Refund): Promise<string | undefined> {
|
|
533
|
-
logger.info('Processing onchain credit refund', { refundId: refund.id, invoiceId: refund.invoice_id });
|
|
523
|
+
export async function handleCreditRefund(refund: Refund): Promise<string | undefined> {
|
|
524
|
+
logger.info('Processing credit refund', { refundId: refund.id, invoiceId: refund.invoice_id });
|
|
534
525
|
|
|
535
526
|
// If no invoice_id, no credit grants to burn
|
|
536
527
|
if (!refund.invoice_id) {
|
|
@@ -549,16 +540,14 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
|
|
|
549
540
|
throw new Error(`Invoice ${refund.invoice_id} total is zero or negative`);
|
|
550
541
|
}
|
|
551
542
|
|
|
552
|
-
// Find ALL credit grants related to this invoice (
|
|
543
|
+
// Find ALL credit grants related to this invoice (both on-chain and off-chain).
|
|
553
544
|
const allGrants = await CreditGrant.findAll({
|
|
554
545
|
where: {
|
|
555
546
|
customer_id: refund.customer_id,
|
|
556
547
|
status: ['pending', 'granted'],
|
|
557
548
|
},
|
|
558
549
|
});
|
|
559
|
-
const creditGrants = allGrants.filter(
|
|
560
|
-
(grant) => grant.metadata?.invoice_id === refund.invoice_id && CreditGrant.hasOnchainToken(grant)
|
|
561
|
-
);
|
|
550
|
+
const creditGrants = allGrants.filter((grant) => grant.metadata?.invoice_id === refund.invoice_id);
|
|
562
551
|
|
|
563
552
|
if (creditGrants.length === 0) {
|
|
564
553
|
logger.info('No active credit grants found for refund', { refundId: refund.id, invoiceId: refund.invoice_id });
|
|
@@ -583,8 +572,19 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
|
|
|
583
572
|
// eslint-disable-next-line no-await-in-loop
|
|
584
573
|
const grantCurrency = await PaymentCurrency.findByPk(creditGrant.currency_id);
|
|
585
574
|
|
|
586
|
-
|
|
587
|
-
|
|
575
|
+
if (!grantCurrency) {
|
|
576
|
+
logger.error('Payment currency not found for credit grant refund', {
|
|
577
|
+
creditGrantId: creditGrant.id,
|
|
578
|
+
currencyId: creditGrant.currency_id,
|
|
579
|
+
});
|
|
580
|
+
// eslint-disable-next-line no-continue
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const isOnchainGrant = CreditGrant.hasOnchainToken(creditGrant);
|
|
585
|
+
|
|
586
|
+
// On-chain path requires token_config to run the burn; off-chain path doesn't touch the chain.
|
|
587
|
+
if (isOnchainGrant && !grantCurrency.token_config) {
|
|
588
588
|
logger.error('Payment currency token config not found for burn', {
|
|
589
589
|
creditGrantId: creditGrant.id,
|
|
590
590
|
currencyId: creditGrant.currency_id,
|
|
@@ -608,6 +608,7 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
|
|
|
608
608
|
logger.info('Processing credit grant for refund', {
|
|
609
609
|
creditGrantId: creditGrant.id,
|
|
610
610
|
currencyId: grantCurrency.id,
|
|
611
|
+
isOnchainGrant,
|
|
611
612
|
grantTotal: grantTotalAmount.toString(),
|
|
612
613
|
grantRemaining: grantRemainingAmount.toString(),
|
|
613
614
|
shouldBurn: shouldBurnFromGrant.toString(),
|
|
@@ -617,70 +618,81 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
|
|
|
617
618
|
|
|
618
619
|
let burnHash: string | null = null;
|
|
619
620
|
let burnError: string | null = null;
|
|
621
|
+
let clawedBackAmount = new BN(0);
|
|
620
622
|
|
|
621
|
-
// Only burn if user has remaining tokens
|
|
622
623
|
if (actualBurnAmount.gt(new BN(0))) {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const customerState = await getAccountState(grantCurrency, customer.did);
|
|
626
|
-
|
|
627
|
-
try {
|
|
624
|
+
if (isOnchainGrant) {
|
|
625
|
+
// Get the latest DID for burn (handles DID migration via on-chain state)
|
|
628
626
|
// eslint-disable-next-line no-await-in-loop
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
627
|
+
const customerState = await getAccountState(grantCurrency, customer.did);
|
|
628
|
+
|
|
629
|
+
try {
|
|
630
|
+
// eslint-disable-next-line no-await-in-loop
|
|
631
|
+
burnHash = await burnToken({
|
|
632
|
+
paymentCurrency: grantCurrency,
|
|
633
|
+
amount: fromUnitToToken(actualBurnAmount.toString(), grantCurrency.decimal),
|
|
634
|
+
sender: customerState?.address || customer.did,
|
|
635
|
+
data: {
|
|
636
|
+
reason: 'credit_grant_refund',
|
|
637
|
+
creditGrantId: creditGrant.id,
|
|
638
|
+
refundId: refund.id,
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
clawedBackAmount = actualBurnAmount;
|
|
643
|
+
totalBurnedFromUser = totalBurnedFromUser.add(actualBurnAmount);
|
|
644
|
+
|
|
645
|
+
logger.info('Successfully burned user tokens for refund', {
|
|
646
|
+
creditGrantId: creditGrant.id,
|
|
647
|
+
burnHash,
|
|
648
|
+
amount: actualBurnAmount.toString(),
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// Emit burned event to trigger reconciliation
|
|
652
|
+
events.emit('customer.credit_grant.burned', creditGrant, {
|
|
653
|
+
burnHash,
|
|
654
|
+
burnedAmount: actualBurnAmount.toString(),
|
|
655
|
+
});
|
|
656
|
+
} catch (error: any) {
|
|
657
|
+
logger.error('Failed to burn user tokens for refund', {
|
|
635
658
|
creditGrantId: creditGrant.id,
|
|
636
659
|
refundId: refund.id,
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
660
|
+
error: error.message,
|
|
661
|
+
});
|
|
662
|
+
burnError = error.message;
|
|
663
|
+
lastError = error.message;
|
|
664
|
+
}
|
|
665
|
+
} else {
|
|
666
|
+
// Off-chain grant: refund has already transferred value back to the user; just roll back the ledger.
|
|
667
|
+
clawedBackAmount = actualBurnAmount;
|
|
640
668
|
totalBurnedFromUser = totalBurnedFromUser.add(actualBurnAmount);
|
|
641
|
-
|
|
642
|
-
logger.info('Successfully burned user tokens for refund', {
|
|
643
|
-
creditGrantId: creditGrant.id,
|
|
644
|
-
burnHash,
|
|
645
|
-
amount: actualBurnAmount.toString(),
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
// Emit burned event to trigger reconciliation
|
|
649
|
-
events.emit('customer.credit_grant.burned', creditGrant, {
|
|
650
|
-
burnHash,
|
|
651
|
-
burnedAmount: actualBurnAmount.toString(),
|
|
652
|
-
});
|
|
653
|
-
} catch (error: any) {
|
|
654
|
-
logger.error('Failed to burn user tokens for refund', {
|
|
655
|
-
creditGrantId: creditGrant.id,
|
|
656
|
-
refundId: refund.id,
|
|
657
|
-
error: error.message,
|
|
658
|
-
});
|
|
659
|
-
burnError = error.message;
|
|
660
|
-
lastError = error.message;
|
|
661
669
|
}
|
|
662
670
|
}
|
|
663
671
|
|
|
664
672
|
// Update credit grant using new chain_detail field
|
|
665
|
-
const newRemainingAmount =
|
|
666
|
-
? grantRemainingAmount.sub(actualBurnAmount).toString()
|
|
667
|
-
: grantRemainingAmount.toString();
|
|
673
|
+
const newRemainingAmount = grantRemainingAmount.sub(clawedBackAmount).toString();
|
|
668
674
|
const isFullyVoided = new BN(newRemainingAmount).eq(new BN(0));
|
|
669
675
|
const chainDetail = creditGrant.chain_detail || {};
|
|
670
676
|
|
|
677
|
+
// chain_status only applies to on-chain grants; leave off-chain grants' chain_status untouched.
|
|
678
|
+
let chainStatusUpdate: CreditGrant['chain_status'] | undefined;
|
|
679
|
+
if (isOnchainGrant) {
|
|
680
|
+
if (burnHash) chainStatusUpdate = 'burn_completed';
|
|
681
|
+
else if (burnError) chainStatusUpdate = 'burn_failed';
|
|
682
|
+
}
|
|
683
|
+
|
|
671
684
|
// eslint-disable-next-line no-await-in-loop
|
|
672
685
|
await creditGrant.update({
|
|
673
686
|
status: isFullyVoided ? 'voided' : 'granted',
|
|
674
687
|
remaining_amount: newRemainingAmount,
|
|
675
688
|
voided_at: isFullyVoided ? Math.floor(Date.now() / 1000) : undefined,
|
|
676
|
-
|
|
677
|
-
chain_status: burnHash ? 'burn_completed' : burnError ? 'burn_failed' : undefined,
|
|
689
|
+
...(chainStatusUpdate ? { chain_status: chainStatusUpdate } : {}),
|
|
678
690
|
chain_detail: {
|
|
679
691
|
...chainDetail,
|
|
680
692
|
refund: {
|
|
681
693
|
id: refund.id,
|
|
682
694
|
burn_hash: burnHash || undefined,
|
|
683
|
-
burned_amount:
|
|
695
|
+
burned_amount: clawedBackAmount.toString(),
|
|
684
696
|
system_retained: systemRetainedAmount.toString(),
|
|
685
697
|
burn_error: burnError || undefined,
|
|
686
698
|
},
|
|
@@ -690,14 +702,15 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
|
|
|
690
702
|
|
|
691
703
|
logger.info('Credit grant processed for refund', {
|
|
692
704
|
creditGrantId: creditGrant.id,
|
|
705
|
+
isOnchainGrant,
|
|
693
706
|
newStatus: isFullyVoided ? 'voided' : 'granted',
|
|
694
707
|
newRemainingAmount,
|
|
695
|
-
|
|
708
|
+
clawedBack: clawedBackAmount.toString(),
|
|
696
709
|
systemRetained: systemRetainedAmount.toString(),
|
|
697
710
|
});
|
|
698
711
|
}
|
|
699
712
|
|
|
700
|
-
logger.info('Completed
|
|
713
|
+
logger.info('Completed credit refund processing', {
|
|
701
714
|
refundId: refund.id,
|
|
702
715
|
processedCount: creditGrants.length,
|
|
703
716
|
totalBurnedFromUser: totalBurnedFromUser.toString(),
|
|
@@ -4,6 +4,7 @@ import { BN } from '@ocap/util';
|
|
|
4
4
|
import { Op } from 'sequelize';
|
|
5
5
|
import { createEvent } from '../libs/audit';
|
|
6
6
|
import { ensurePassportRevoked } from '../integrations/blocklet/passport';
|
|
7
|
+
// eslint-disable-next-line import/no-cycle
|
|
7
8
|
import { batchHandleStripeSubscriptions } from '../integrations/stripe/resource';
|
|
8
9
|
import { wallet } from '../libs/auth';
|
|
9
10
|
import { getExchangeRateService } from '../libs/exchange-rate';
|
|
@@ -1617,6 +1617,29 @@ router.post('/:id/skip-payment-method', user, ensureCheckoutSessionOpen, async (
|
|
|
1617
1617
|
});
|
|
1618
1618
|
|
|
1619
1619
|
// for checkout page
|
|
1620
|
+
// Lightweight status-only endpoint for polling.
|
|
1621
|
+
// Returns only status fields — no includes, no joins, single D1 query.
|
|
1622
|
+
// Used by waitForCheckoutComplete to minimize D1 load during payment processing.
|
|
1623
|
+
router.get('/status/:id', user, async (req, res) => {
|
|
1624
|
+
const doc = await CheckoutSession.findByPk(req.params.id, {
|
|
1625
|
+
attributes: ['id', 'status', 'payment_status', 'payment_intent_id'],
|
|
1626
|
+
});
|
|
1627
|
+
if (!doc) {
|
|
1628
|
+
return res.status(404).json({ error: 'Checkout session not found' });
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const piStatus = doc.payment_intent_id
|
|
1632
|
+
? await PaymentIntent.findByPk(doc.payment_intent_id, {
|
|
1633
|
+
attributes: ['status', 'last_payment_error'],
|
|
1634
|
+
}).then((pi) => (pi ? { status: pi.status, last_payment_error: pi.last_payment_error } : null))
|
|
1635
|
+
: null;
|
|
1636
|
+
|
|
1637
|
+
return res.json({
|
|
1638
|
+
checkoutSession: { status: doc.status, payment_status: doc.payment_status },
|
|
1639
|
+
paymentIntent: piStatus,
|
|
1640
|
+
});
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1620
1643
|
router.get('/retrieve/:id', user, async (req, res) => {
|
|
1621
1644
|
const doc = await CheckoutSession.findByPk(req.params.id);
|
|
1622
1645
|
|
|
@@ -1625,9 +1648,10 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1625
1648
|
return;
|
|
1626
1649
|
}
|
|
1627
1650
|
|
|
1628
|
-
|
|
1651
|
+
// Check if subscription status needs updating (conditional write — must happen before reads)
|
|
1652
|
+
let subscriptionIds: string[] = [];
|
|
1629
1653
|
if (['subscription', 'setup'].includes(doc.mode)) {
|
|
1630
|
-
|
|
1654
|
+
subscriptionIds = getCheckoutSessionSubscriptionIds(doc);
|
|
1631
1655
|
if (
|
|
1632
1656
|
doc.success_subscription_count &&
|
|
1633
1657
|
doc.success_subscription_count >= subscriptionIds.length &&
|
|
@@ -1638,41 +1662,56 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1638
1662
|
payment_status: 'paid',
|
|
1639
1663
|
});
|
|
1640
1664
|
}
|
|
1641
|
-
subscriptions = await Subscription.findAll({
|
|
1642
|
-
where: { id: subscriptionIds },
|
|
1643
|
-
attributes: ['id', 'description', 'status', 'current_period_start', 'current_period_end', 'latest_invoice_id'],
|
|
1644
|
-
});
|
|
1645
1665
|
}
|
|
1646
1666
|
|
|
1667
|
+
// Parallelize: Subscription lookup + Price.expand are independent (save ~85ms D1 RTT)
|
|
1647
1668
|
const rawLineItems = doc.line_items;
|
|
1669
|
+
const [subscriptions, expandedLineItems] = await Promise.all([
|
|
1670
|
+
subscriptionIds.length > 0
|
|
1671
|
+
? Subscription.findAll({
|
|
1672
|
+
where: { id: subscriptionIds },
|
|
1673
|
+
attributes: [
|
|
1674
|
+
'id',
|
|
1675
|
+
'description',
|
|
1676
|
+
'status',
|
|
1677
|
+
'current_period_start',
|
|
1678
|
+
'current_period_end',
|
|
1679
|
+
'latest_invoice_id',
|
|
1680
|
+
],
|
|
1681
|
+
})
|
|
1682
|
+
: ([] as Subscription[]),
|
|
1683
|
+
Price.expand(rawLineItems, { upsell: true }),
|
|
1684
|
+
]);
|
|
1648
1685
|
// @ts-ignore
|
|
1649
|
-
doc.line_items =
|
|
1686
|
+
doc.line_items = expandedLineItems;
|
|
1650
1687
|
|
|
1651
1688
|
// Recover discount config for open sessions if checkout_session.discounts was unexpectedly cleared.
|
|
1652
1689
|
if (!doc.discounts?.length && doc.status === 'open' && doc.payment_status !== 'paid') {
|
|
1653
1690
|
await recoverDiscountConfigFromRecords(doc);
|
|
1654
1691
|
}
|
|
1655
1692
|
|
|
1656
|
-
//
|
|
1693
|
+
// Parallelize: coupon info expansion + discount details are independent (save ~85ms D1 RTT)
|
|
1694
|
+
let enhancedDiscounts: any;
|
|
1657
1695
|
if (doc.discounts?.length) {
|
|
1658
|
-
|
|
1659
|
-
doc.line_items as TLineItemExpanded[],
|
|
1660
|
-
doc.discounts,
|
|
1661
|
-
|
|
1662
|
-
|
|
1696
|
+
const [expandedItems, discountDetails] = await Promise.all([
|
|
1697
|
+
expandLineItemsWithCouponInfo(doc.line_items as TLineItemExpanded[], doc.discounts, doc.currency_id),
|
|
1698
|
+
expandDiscountsWithDetails(doc.discounts),
|
|
1699
|
+
]);
|
|
1700
|
+
doc.line_items = expandedItems;
|
|
1701
|
+
enhancedDiscounts = discountDetails;
|
|
1702
|
+
} else {
|
|
1703
|
+
enhancedDiscounts = await expandDiscountsWithDetails(doc.discounts);
|
|
1663
1704
|
}
|
|
1664
1705
|
|
|
1665
|
-
// Expand discounts with complete details
|
|
1666
|
-
const enhancedDiscounts = await expandDiscountsWithDetails(doc.discounts);
|
|
1667
|
-
|
|
1668
1706
|
// Handle dynamic pricing: create or reuse quotes
|
|
1669
|
-
// Skip quote generation when checkout session is confirmed and should not change:
|
|
1670
|
-
// 1. Payment completed (payment_status === 'paid')
|
|
1671
|
-
// 2. Subscription created (has subscription_id) - includes free trial scenarios
|
|
1672
|
-
// 3. Status is 'complete'
|
|
1673
1707
|
const isCheckoutConfirmed = doc.payment_status === 'paid' || doc.status === 'complete' || !!doc.subscription_id;
|
|
1674
1708
|
const forceRefresh = req.query.forceRefresh === '1' || req.query.forceRefresh === 'true';
|
|
1675
1709
|
|
|
1710
|
+
// Start PaymentIntent fetch early — runs in parallel with quote processing (save ~85ms D1 RTT)
|
|
1711
|
+
const paymentIntentPromise = doc.payment_intent_id
|
|
1712
|
+
? PaymentIntent.findByPk(doc.payment_intent_id)
|
|
1713
|
+
: Promise.resolve(null);
|
|
1714
|
+
|
|
1676
1715
|
let quotes = {};
|
|
1677
1716
|
let rateUnavailable = false;
|
|
1678
1717
|
let rateError: string | undefined;
|
|
@@ -1740,8 +1779,8 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1740
1779
|
rateError = error.message || 'Failed to process price quotes';
|
|
1741
1780
|
}
|
|
1742
1781
|
|
|
1743
|
-
//
|
|
1744
|
-
const paymentIntent =
|
|
1782
|
+
// Await PaymentIntent that was started in parallel with quotes
|
|
1783
|
+
const paymentIntent = await paymentIntentPromise;
|
|
1745
1784
|
if ((forceRefresh || quotesRefreshed) && !isCheckoutConfirmed) {
|
|
1746
1785
|
if (doc.metadata?.quote_locked_at) {
|
|
1747
1786
|
await doc.update({
|
|
@@ -1753,19 +1792,26 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1753
1792
|
}
|
|
1754
1793
|
}
|
|
1755
1794
|
|
|
1795
|
+
// Parallelize independent lookups — saves 2 D1 round-trips (~170ms)
|
|
1796
|
+
const [paymentMethods, paymentLink, customer] = await Promise.all([
|
|
1797
|
+
getPaymentMethods(doc),
|
|
1798
|
+
doc.payment_link_id ? PaymentLink.findByPk(doc.payment_link_id) : null,
|
|
1799
|
+
req.user ? Customer.findOne({ where: { did: req.user.did } }) : null,
|
|
1800
|
+
]);
|
|
1801
|
+
|
|
1756
1802
|
res.json({
|
|
1757
1803
|
checkoutSession: {
|
|
1758
1804
|
...doc.toJSON(),
|
|
1759
1805
|
discounts: enhancedDiscounts,
|
|
1760
1806
|
subscriptions,
|
|
1761
1807
|
},
|
|
1762
|
-
quotes,
|
|
1763
|
-
rateUnavailable,
|
|
1764
|
-
rateError,
|
|
1765
|
-
paymentMethods
|
|
1766
|
-
paymentLink
|
|
1808
|
+
quotes,
|
|
1809
|
+
rateUnavailable,
|
|
1810
|
+
rateError,
|
|
1811
|
+
paymentMethods,
|
|
1812
|
+
paymentLink,
|
|
1767
1813
|
paymentIntent,
|
|
1768
|
-
customer
|
|
1814
|
+
customer,
|
|
1769
1815
|
...(stopAcceptingOrders ? { stopAcceptingOrders: true } : {}),
|
|
1770
1816
|
});
|
|
1771
1817
|
});
|
|
@@ -2623,7 +2669,6 @@ async function prefetchQuotesForCheckoutSession(checkoutSession: CheckoutSession
|
|
|
2623
2669
|
router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
2624
2670
|
let consumedQuotes: PriceQuote[] = [];
|
|
2625
2671
|
const checkoutSession = req.doc as CheckoutSession;
|
|
2626
|
-
|
|
2627
2672
|
try {
|
|
2628
2673
|
if (!req.user) {
|
|
2629
2674
|
return res.status(403).json({ code: 'REQUIRE_LOGIN', error: 'Please login to continue' });
|
|
@@ -3696,10 +3741,17 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
3696
3741
|
}
|
|
3697
3742
|
}
|
|
3698
3743
|
|
|
3699
|
-
|
|
3744
|
+
// Parallel: load currency + customer (independent lookups)
|
|
3745
|
+
const [paymentCurrency, customer] = await Promise.all([
|
|
3746
|
+
PaymentCurrency.findByPk(checkoutSession.currency_id),
|
|
3747
|
+
Customer.findByPkOrDid(req.user.did),
|
|
3748
|
+
]);
|
|
3700
3749
|
if (!paymentCurrency) {
|
|
3701
3750
|
return res.status(400).json({ error: 'Payment currency not found' });
|
|
3702
3751
|
}
|
|
3752
|
+
if (!customer) {
|
|
3753
|
+
return res.status(400).json({ error: '' });
|
|
3754
|
+
}
|
|
3703
3755
|
|
|
3704
3756
|
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
3705
3757
|
if (!paymentMethod) {
|
|
@@ -3717,12 +3769,6 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
3717
3769
|
}
|
|
3718
3770
|
}
|
|
3719
3771
|
|
|
3720
|
-
const customer = await Customer.findByPkOrDid(req.user.did);
|
|
3721
|
-
if (!customer) {
|
|
3722
|
-
return res.status(400).json({ error: '' });
|
|
3723
|
-
}
|
|
3724
|
-
|
|
3725
|
-
// check if customer can make new purchase
|
|
3726
3772
|
const canMakeNewPurchase = await customer.canMakeNewPurchase(checkoutSession.invoice_id);
|
|
3727
3773
|
if (!canMakeNewPurchase) {
|
|
3728
3774
|
return res.status(403).json({
|
|
@@ -3890,7 +3936,6 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
3890
3936
|
canFastPay = true;
|
|
3891
3937
|
}
|
|
3892
3938
|
} else if (isPayment && paymentIntent && canFastPay) {
|
|
3893
|
-
// if we can complete purchase without any wallet interaction
|
|
3894
3939
|
delegation = await isDelegationSufficientForPayment({
|
|
3895
3940
|
paymentMethod,
|
|
3896
3941
|
paymentCurrency,
|
|
@@ -3956,12 +4001,6 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
3956
4001
|
await quoteService.markQuotesAsPaidByInvoice(paymentIntent.invoice_id);
|
|
3957
4002
|
}
|
|
3958
4003
|
|
|
3959
|
-
logger.info('Checkout session submitted successfully', {
|
|
3960
|
-
sessionId: req.params.id,
|
|
3961
|
-
paymentIntentId: paymentIntent?.id,
|
|
3962
|
-
customerId: customer?.id,
|
|
3963
|
-
});
|
|
3964
|
-
|
|
3965
4004
|
return res.json({
|
|
3966
4005
|
paymentIntent,
|
|
3967
4006
|
checkoutSession,
|
|
@@ -114,6 +114,8 @@ export default {
|
|
|
114
114
|
billingThreshold,
|
|
115
115
|
items,
|
|
116
116
|
slippageConfig: subscription?.slippage_config || undefined,
|
|
117
|
+
// Reuse DelegateState fetched above — avoids a duplicate chain RPC on CF cold isolates
|
|
118
|
+
delegateState: delegation.state,
|
|
117
119
|
}),
|
|
118
120
|
});
|
|
119
121
|
}
|