payment-kit 1.29.4 → 1.29.6

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.
@@ -169,7 +169,10 @@ function init() {
169
169
  {
170
170
  name: 'deposit.vault',
171
171
  time: depositVaultCronTime(),
172
- fn: () => startDepositVaultQueue(),
172
+ // startDepositVaultQueue uses a SCOPED findAll (not systemFindAll), so it
173
+ // must run inside a tenant — perTenant fans it out per provisioned tenant
174
+ // (multi) or runs it once for the default tenant (single).
175
+ fn: perTenant('deposit.vault', () => startDepositVaultQueue()),
173
176
  options: { runOnInit: true },
174
177
  },
175
178
  {
@@ -180,6 +180,22 @@ export const sqlBenchmark = (): boolean => readConfig('SQL_BENCHMARK') === '1';
180
180
  export const cfEnv = (): any => (globalThis as any).__CF_ENV__;
181
181
  export const isCfWorker = (): boolean => !!cfEnv();
182
182
 
183
+ /**
184
+ * THE single, reusable gate for Blocklet-Server-ONLY features.
185
+ *
186
+ * True only under Blocklet Server, where the full @blocklet/sdk host runtime is
187
+ * present — BLOCKLET_APP_ID plus the rest of the env the SDK's
188
+ * `checkBlockletEnvironment` requires (BLOCKLET_DID / BLOCKLET_APP_EK / ABT_NODE_*).
189
+ * BLOCKLET_APP_ID is the canonical marker; arc-node and the CF worker run the
190
+ * embedded core with NONE of it.
191
+ *
192
+ * Any integration that only works on Blocklet Server (the @blocklet/sdk
193
+ * notification / eventbus / relay transports, and any future host-service feature)
194
+ * MUST gate on this and skip itself off Blocklet Server — never branch on
195
+ * isCfWorker()/hasDynamicIdentity() ad hoc, so "what is BS-only" stays in one place.
196
+ */
197
+ export const isBlockletServer = (): boolean => hasConfig('BLOCKLET_APP_ID');
198
+
183
199
  export default {
184
200
  ...env,
185
201
  };
@@ -3,10 +3,53 @@ import BlockletNotification from '@blocklet/sdk/service/notification';
3
3
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './template/base';
4
4
  import { CheckoutSession, CreditGrant, Invoice, Meter, MeterEvent, Subscription } from '../../store/models';
5
5
  import { getNotificationSettings, shouldSendSystemNotification } from '../setting';
6
+ import { isBlockletServer } from '../env';
6
7
  import logger from '../logger';
7
8
  import { events } from '../event';
8
9
  import { createFlexibleEvent } from '../audit';
9
10
 
11
+ let blockletSdkTransportGuarded = false;
12
+
13
+ /** Replace `fns` with no-ops on both the module namespace and its `default` export. */
14
+ function noopSdkTransport(mod: any, fns: string[]): void {
15
+ const noop = async (): Promise<undefined> => undefined;
16
+ for (const target of [mod, mod?.default]) {
17
+ if (!target) continue;
18
+ for (const fn of fns) target[fn] = noop;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Several @blocklet/sdk service transports run `checkBlockletEnvironment`, which
24
+ * throws `BLOCKLET_APP_ID does not exist in environments` when that env is absent:
25
+ * - service/notification → sendToUser / sendToRelay / sendToMail
26
+ * - service/eventbus → publish / subscribe / unsubscribe
27
+ * BLOCKLET_APP_ID only exists under blocklet-server; arc-node and the CF worker have
28
+ * NO such transport, so the post-payment notification job crashed and every event
29
+ * broadcast logged a "Failed to publish event via EventBus" error.
30
+ *
31
+ * Off blocklet-server we replace those transport functions with no-ops — at the SDK
32
+ * module level, not the call sites, so the app-side logic (notification settings
33
+ * check, the `events` emit that drives `broadcast`) still runs and only the actual
34
+ * send/publish is skipped. One patch covers every caller: __esModule is true, so
35
+ * esbuild's __toESM keeps live getters and every `import … from '@blocklet/sdk/...'`
36
+ * (default OR namespace) resolves to this same module object at call time — hence
37
+ * patching both `mod` and `mod.default`.
38
+ *
39
+ * Idempotent; gated on the exact env the SDK checks, so blocklet-server is byte-for-
40
+ * byte unchanged (it returns before requiring/mutating anything).
41
+ */
42
+ export function ensureBlockletSdkTransportGuard(): void {
43
+ if (blockletSdkTransportGuarded) return;
44
+ blockletSdkTransportGuarded = true;
45
+ if (isBlockletServer()) return; // Blocklet Server: real transports, leave them intact
46
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
47
+ noopSdkTransport(require('@blocklet/sdk/service/notification'), ['sendToUser', 'sendToRelay', 'sendToMail']);
48
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
49
+ noopSdkTransport(require('@blocklet/sdk/service/eventbus'), ['publish', 'subscribe', 'unsubscribe']);
50
+ logger.info('[blocklet-sdk] notification + eventbus transport disabled — no BLOCKLET_APP_ID (off blocklet-server)');
51
+ }
52
+
10
53
  export class Notification {
11
54
  template: BaseEmailTemplate;
12
55
  type?: string;
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/indent */
2
2
  import { isEthereumDid } from '@arcblock/did';
3
3
  import { getWalletDid } from '@blocklet/sdk/lib/did';
4
+ import type OcapClient from '@ocap/client';
4
5
  import type { DelegateState, TokenLimit } from '@ocap/client';
5
6
  import { toTxHash } from '@ocap/mcrypto';
6
7
  import { BN, fromUnitToToken } from '@ocap/util';
@@ -21,7 +22,12 @@ import {
21
22
  Customer,
22
23
  Subscription,
23
24
  } from '../store/models';
24
- import { getDelegationAddressWithFallback, backfillDelegationAddress, getMigratedFromList } from './wallet-migration';
25
+ import {
26
+ getDelegationAddressWithFallback,
27
+ backfillDelegationAddress,
28
+ getMigratedFromList,
29
+ getAppStakeAddressWithFallback,
30
+ } from './wallet-migration';
25
31
  import type { TPaymentCurrency } from '../store/models/payment-currency';
26
32
  import { blocklet, ethWallet, wallet, getVaultAddress } from './auth';
27
33
  import logger from './logger';
@@ -520,8 +526,27 @@ export function isCreditSufficientForPayment(args: {
520
526
  return { sufficient: true, balance };
521
527
  }
522
528
 
523
- export async function getGasPayerExtra(txBuffer: Buffer, headers?: { [key: string]: string }) {
524
- if (headers && headers['x-gas-payer-sig'] && headers['x-gas-payer-pk']) {
529
+ export async function getGasPayerExtra(
530
+ txBuffer: Buffer,
531
+ headers?: { [key: string]: string },
532
+ // The ocap client for the tx's chain. When provided AND the app has staked for
533
+ // gas, the app is the deliberate sponsor for this method's payments: use ITS OWN
534
+ // gas-payer header and IGNORE any wallet-provided one. ArcWallet attaches a
535
+ // gas-payer header to every wallet-initiated tx, so without this gate the app's
536
+ // staked sponsor (the self-sign branch below) is never reached — the chain
537
+ // rejects the wallet's header and falls back to charging `from` (the user's
538
+ // possibly-empty account) → GAS_PAYER_NOT_ON_CHAIN. Best-effort: any stake-check
539
+ // error keeps the wallet header (prior behavior), so a flaky chain read never
540
+ // blocks a payment.
541
+ client?: OcapClient
542
+ ) {
543
+ const appStaked = client
544
+ ? await getAppStakeAddressWithFallback({ client })
545
+ .then((r) => !!r)
546
+ .catch(() => false)
547
+ : false;
548
+
549
+ if (!appStaked && headers && headers['x-gas-payer-sig'] && headers['x-gas-payer-pk']) {
525
550
  return { headers };
526
551
  }
527
552
 
@@ -55,16 +55,28 @@ export function contextMiddleware(): MiddlewareHandler {
55
55
  requestedBy = user.did;
56
56
  }
57
57
 
58
- // Resolve tenant from the raw Host (single point). Multi-mode unknown host
59
- // 4xx fail-closed, no default-tenant fallback.
58
+ // Resolve tenant from the raw Host (single point) UNLESS the host already
59
+ // scoped this request (e.g. the CF worker wrapped /.well-known/payment/* in
60
+ // withTenant before the core ran, and set its identity driver's
61
+ // resolveInstanceDidForHost to null on purpose). Honor that pre-established
62
+ // context instead of re-resolving, mirroring the DID-Connect tenant middleware
63
+ // (service.ts buildConnectRoutesHono) so native and connect routes behave
64
+ // identically. blocklet-server / arc-node never pre-wrap → peek is undefined →
65
+ // the Host resolution path is unchanged. Multi-mode unknown host → 4xx
66
+ // fail-closed, no default-tenant fallback.
60
67
  let instanceDid: string;
61
- try {
62
- instanceDid = await resolveTenantForHost(c.req.header('host'));
63
- } catch (err) {
64
- if (err instanceof TenantError && err.code === TENANT_HOST_UNRESOLVED) {
65
- return c.json({ error: { code: err.code, message: err.message } }, 400);
68
+ const preResolved = context.peekInstanceDid();
69
+ if (preResolved) {
70
+ instanceDid = preResolved;
71
+ } else {
72
+ try {
73
+ instanceDid = await resolveTenantForHost(c.req.header('host'));
74
+ } catch (err) {
75
+ if (err instanceof TenantError && err.code === TENANT_HOST_UNRESOLVED) {
76
+ return c.json({ error: { code: err.code, message: err.message } }, 400);
77
+ }
78
+ throw err;
66
79
  }
67
- throw err;
68
80
  }
69
81
 
70
82
  return context.run({ requestId, requestedBy, instanceDid }, async () => {
@@ -6,6 +6,7 @@ import pAll from 'p-all';
6
6
  import dayjs from '../libs/dayjs';
7
7
  import createQueue, { assertJobObjectTenant } from '../libs/queue';
8
8
  import { systemFindAll, systemFindByPk } from '../store/scoped';
9
+ import { withTenant } from '../libs/context';
9
10
  import {
10
11
  AutoRechargeConfig,
11
12
  CreditGrant,
@@ -440,7 +441,10 @@ export const startCreditGrantQueue = async () => {
440
441
 
441
442
  const invoiceResults = await Promise.allSettled(
442
443
  invoicesToSchedule.map(async (invoice) => {
443
- await addInvoiceCreditJob(invoice.id);
444
+ // Cross-tenant scan → enqueue in the invoice's own tenant (push stamps the job
445
+ // tenant via getInstanceDid, fail-closed in multi otherwise).
446
+ const dispatch = () => addInvoiceCreditJob(invoice.id);
447
+ await (invoice.instance_did ? withTenant(invoice.instance_did, dispatch) : dispatch());
444
448
  return invoice.id;
445
449
  })
446
450
  );
@@ -454,7 +458,8 @@ export const startCreditGrantQueue = async () => {
454
458
 
455
459
  const results = await Promise.allSettled(
456
460
  grantsToSchedule.map(async (grant) => {
457
- await scheduleCreditGrantJobs(grant);
461
+ const dispatch = () => scheduleCreditGrantJobs(grant);
462
+ await (grant.instance_did ? withTenant(grant.instance_did, dispatch) : dispatch());
458
463
  return grant.id;
459
464
  })
460
465
  );
@@ -1,4 +1,5 @@
1
1
  import pAll from 'p-all';
2
+ import { withTenant } from '../libs/context';
2
3
  import createQueue, { assertJobObjectTenant } from '../libs/queue';
3
4
  import { Coupon, PromotionCode } from '../store/models';
4
5
  import logger from '../libs/logger';
@@ -151,21 +152,26 @@ export const startDiscountStatusQueue = async () => {
151
152
 
152
153
  const coupons = await systemFindAll(Coupon, { where: { valid: true } });
153
154
  const promotionCodes = await systemFindAll(PromotionCode, { where: { active: true } });
155
+
156
+ // Cross-tenant scan: addDiscountStatusJob pushes, which stamps the job tenant via
157
+ // getInstanceDid (fails closed in multi mode without context). Run each enqueue in
158
+ // its record's tenant; per-record catch isolates one bad row from the whole pass.
159
+ const enqueue = (record: Coupon | PromotionCode, type: DiscountType, options: { runAt?: number }) => async () => {
160
+ const dispatch = () => addDiscountStatusJob(record, type, false, options);
161
+ try {
162
+ await (record.instance_did ? withTenant(record.instance_did, dispatch) : dispatch());
163
+ } catch (error) {
164
+ logger.error('startDiscountStatusQueue: enqueue failed', { type, id: record.id, error });
165
+ }
166
+ };
167
+
154
168
  await pAll(
155
- coupons.map(
156
- (coupon) => () =>
157
- addDiscountStatusJob(coupon, 'coupon', false, {
158
- runAt: coupon.redeem_by,
159
- })
160
- ),
169
+ coupons.map((coupon) => enqueue(coupon, 'coupon', { runAt: coupon.redeem_by })),
161
170
  { concurrency: 5 }
162
171
  );
163
172
  await pAll(
164
- promotionCodes.map(
165
- (promotionCode) => () =>
166
- addDiscountStatusJob(promotionCode, 'promotion-code', false, {
167
- runAt: promotionCode.expires_at,
168
- })
173
+ promotionCodes.map((promotionCode) =>
174
+ enqueue(promotionCode, 'promotion-code', { runAt: promotionCode.expires_at })
169
175
  ),
170
176
  { concurrency: 5 }
171
177
  );
@@ -1,5 +1,7 @@
1
1
  import { Op } from 'sequelize';
2
2
  import { systemFindAll, systemFindByPk } from '../store/scoped';
3
+ import { withTenant } from '../libs/context';
4
+ import { perTenant } from '../crons/tenant-fanout';
3
5
 
4
6
  import { getInvoiceShouldPayTotal, handleOverdraftProtectionInvoiceAfterPayment } from '../libs/invoice';
5
7
  import { createEvent, reportAuditFailure } from '../libs/audit';
@@ -231,15 +233,20 @@ export const startInvoiceQueue = async () => {
231
233
  });
232
234
 
233
235
  const results = await Promise.allSettled(
234
- invoices.map(async (x) => {
235
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
236
- if (supportAutoCharge === false) {
237
- return;
238
- }
239
- const exist = await invoiceQueue.get(x.id);
240
- if (!exist) {
241
- await invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
242
- }
236
+ invoices.map((x) => {
237
+ const dispatch = async () => {
238
+ const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
239
+ if (supportAutoCharge === false) {
240
+ return;
241
+ }
242
+ const exist = await invoiceQueue.get(x.id);
243
+ if (!exist) {
244
+ await invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
245
+ }
246
+ };
247
+ // Cross-tenant scan → run each invoice's enqueue in its own tenant: the push
248
+ // stamps the job tenant via getInstanceDid, fail-closed in multi otherwise.
249
+ return x.instance_did ? withTenant(x.instance_did, dispatch) : dispatch();
243
250
  })
244
251
  );
245
252
 
@@ -248,7 +255,10 @@ export const startInvoiceQueue = async () => {
248
255
  logger.warn(`Failed to process ${failed} invoices in startInvoiceQueue`);
249
256
  }
250
257
 
251
- await batchHandleStripeInvoices();
258
+ // batchHandleStripeInvoices uses scoped findAll → run once per provisioned
259
+ // tenant (perTenant) so its queries resolve a tenant; the stripe.invoice.sync
260
+ // cron uses the same wrapper. Single mode runs it once, unchanged.
261
+ await perTenant('startInvoiceQueue.stripe', batchHandleStripeInvoices)();
252
262
  } catch (error) {
253
263
  logger.error('Error in startInvoiceQueue:', error);
254
264
  } finally {
@@ -4,6 +4,8 @@ import { BN } from '@ocap/util';
4
4
  import { Op } from 'sequelize';
5
5
  import { paymentReloadSubscriptionJobs } from '../libs/env';
6
6
  import { systemFindAll, systemFindByPk, systemFindOne } from '../store/scoped';
7
+ import { withTenant } from '../libs/context';
8
+ import { perTenant } from '../crons/tenant-fanout';
7
9
  import { createEvent, reportAuditFailure } from '../libs/audit';
8
10
  import { ensurePassportRevoked } from '../integrations/blocklet/passport';
9
11
  // eslint-disable-next-line import/no-cycle
@@ -1603,27 +1605,32 @@ export const startSubscriptionQueue = async () => {
1603
1605
  });
1604
1606
 
1605
1607
  const results = await Promise.allSettled(
1606
- subscriptions.map(async (x) => {
1607
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
1608
- if (supportAutoCharge === false) {
1609
- return;
1610
- }
1611
- if (['past_due', 'paused'].includes(x.status)) {
1612
- const willCancel = x.cancel_at || x.cancel_at_period_end;
1613
- if (x.status === 'past_due' && willCancel) {
1614
- const existingJob = await subscriptionQueue.get(`cancel-${x.id}`);
1615
- if (!existingJob) {
1616
- await addSubscriptionJob(x, 'cancel', true, x.cancel_at || x.current_period_end);
1608
+ subscriptions.map((x) => {
1609
+ const dispatch = async () => {
1610
+ const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
1611
+ if (supportAutoCharge === false) {
1612
+ return;
1613
+ }
1614
+ if (['past_due', 'paused'].includes(x.status)) {
1615
+ const willCancel = x.cancel_at || x.cancel_at_period_end;
1616
+ if (x.status === 'past_due' && willCancel) {
1617
+ const existingJob = await subscriptionQueue.get(`cancel-${x.id}`);
1618
+ if (!existingJob) {
1619
+ await addSubscriptionJob(x, 'cancel', true, x.cancel_at || x.current_period_end);
1620
+ }
1617
1621
  }
1622
+ logger.info(`skip add cycle subscription job because status is ${x.status}`, {
1623
+ subscription: x.id,
1624
+ action: 'cycle',
1625
+ });
1626
+ return;
1618
1627
  }
1619
- logger.info(`skip add cycle subscription job because status is ${x.status}`, {
1620
- subscription: x.id,
1621
- action: 'cycle',
1622
- });
1623
- return;
1624
- }
1625
- logger.info('add subscription job', { subscription: x.id, action: 'cycle' });
1626
- await addSubscriptionJob(x, 'cycle', paymentReloadSubscriptionJobs());
1628
+ logger.info('add subscription job', { subscription: x.id, action: 'cycle' });
1629
+ await addSubscriptionJob(x, 'cycle', paymentReloadSubscriptionJobs());
1630
+ };
1631
+ // Cross-tenant scan → run each subscription's enqueue in its own tenant: the
1632
+ // push stamps the job tenant via getInstanceDid, fail-closed in multi otherwise.
1633
+ return x.instance_did ? withTenant(x.instance_did, dispatch) : dispatch();
1627
1634
  })
1628
1635
  );
1629
1636
 
@@ -1632,7 +1639,9 @@ export const startSubscriptionQueue = async () => {
1632
1639
  logger.warn(`Failed to process ${failed} subscriptions in startSubscriptionQueue`);
1633
1640
  }
1634
1641
 
1635
- await batchHandleStripeSubscriptions();
1642
+ // batchHandleStripeSubscriptions uses scoped findAll → run once per provisioned
1643
+ // tenant (perTenant); the stripe.subscription.sync cron uses the same wrapper.
1644
+ await perTenant('startSubscriptionQueue.stripe', batchHandleStripeSubscriptions)();
1636
1645
  } catch (error) {
1637
1646
  logger.error('Error in startSubscriptionQueue:', error);
1638
1647
  } finally {
@@ -1,4 +1,5 @@
1
1
  import { BN } from '@ocap/util';
2
+ import { withTenant } from '../libs/context';
2
3
  import logger from '../libs/logger';
3
4
  import createQueue, { assertJobObjectTenant } from '../libs/queue';
4
5
  import { getAccountState, transferTokenFromCustomer, getCustomerTokenBalance } from '../integrations/arcblock/token';
@@ -313,7 +314,7 @@ export async function startTokenTransferQueue(): Promise<void> {
313
314
  // Process in batches to add jobs
314
315
  await Promise.all(
315
316
  pendingTransactions.map(async (transaction) => {
316
- try {
317
+ const dispatch = async () => {
317
318
  const customer = (await systemFindByPk(Customer, transaction.customer_id))!;
318
319
  const creditGrant = (await systemFindByPk(CreditGrant, transaction.credit_grant_id))!;
319
320
 
@@ -326,6 +327,11 @@ export async function startTokenTransferQueue(): Promise<void> {
326
327
  meterEventId: transaction.source!,
327
328
  subscriptionId: transaction.subscription_id || undefined,
328
329
  });
330
+ };
331
+ try {
332
+ // Cross-tenant scan: run each transaction's enqueue in its own tenant so the
333
+ // push (injectJobTenant -> getInstanceDid) is stamped, not fail-closed in multi.
334
+ await (transaction.instance_did ? withTenant(transaction.instance_did, dispatch) : dispatch());
329
335
  } catch (error: any) {
330
336
  logger.error('Failed to add pending transfer job', {
331
337
  transactionId: transaction.id,
@@ -1,3 +1,4 @@
1
+ import { withTenant } from '../../libs/context';
1
2
  import { events } from '../../libs/event';
2
3
  import logger from '../../libs/logger';
3
4
  import createQueue, { assertJobObjectTenant } from '../../libs/queue';
@@ -157,13 +158,28 @@ export const startVendorCommissionQueue = async () => {
157
158
  },
158
159
  });
159
160
 
160
- payments.forEach(async (x) => {
161
- const id = `vendor-commission-${x.invoice_id}`;
162
- const exist = await vendorCommissionQueue.get(id);
163
- if (!exist && x.invoice_id) {
164
- vendorCommissionQueue.push({ id, job: { invoiceId: x.invoice_id, retryOnError: true } });
161
+ // Each pending intent belongs to a tenant: the queue push stamps the job tenant
162
+ // via getInstanceDid, which fails closed in multi mode without a tenant context.
163
+ // for-of + await, NOT forEach(async): a fire-and-forget rejection here becomes an
164
+ // unhandledRejection -> FATAL on boot. Per-record try/catch isolates one bad row.
165
+ for (const x of payments) {
166
+ const invoiceId = x.invoice_id;
167
+ if (invoiceId) {
168
+ const dispatch = async () => {
169
+ const id = `vendor-commission-${invoiceId}`;
170
+ const exist = await vendorCommissionQueue.get(id);
171
+ if (!exist) {
172
+ vendorCommissionQueue.push({ id, job: { invoiceId, retryOnError: true } });
173
+ }
174
+ };
175
+ try {
176
+ // eslint-disable-next-line no-await-in-loop
177
+ await (x.instance_did ? withTenant(x.instance_did, dispatch) : dispatch());
178
+ } catch (error) {
179
+ logger.error('startVendorCommissionQueue: re-queue failed', { invoiceId, error });
180
+ }
165
181
  }
166
- });
182
+ }
167
183
  };
168
184
 
169
185
  events.on('invoice.paid', async (invoice) => {
@@ -1,5 +1,6 @@
1
1
  import { joinURL } from 'ufo';
2
2
  import { Auth as VendorAuth } from '@blocklet/payment-vendor';
3
+ import { withTenant } from '../../libs/context';
3
4
  import createQueue, { assertJobObjectTenant } from '../../libs/queue';
4
5
  import { CheckoutSession } from '../../store/models/checkout-session';
5
6
  import { ProductVendor } from '../../store/models';
@@ -18,20 +19,34 @@ export const startVendorStatusCheckSchedule = async () => {
18
19
  return;
19
20
  }
20
21
 
22
+ // Cross-tenant scan: each push stamps the job tenant via getInstanceDid, which
23
+ // fails closed in multi mode without a tenant context. Wrap each session's pushes
24
+ // in withTenant; per-session try/catch isolates one bad row. (`continue`, not
25
+ // `return`: a session with no vendor_info must skip — not abort the whole scan.)
21
26
  for (const checkoutSession of checkoutSessions) {
22
27
  const vendorInfo = checkoutSession.vendor_info;
23
- if (!vendorInfo?.length) {
24
- return;
25
- }
26
-
27
- for (const vendor of vendorInfo) {
28
- if (vendor.status === 'sent') {
29
- vendorStatusCheckQueue.push({
30
- id: `vendor-status-check-${checkoutSession.id}-${vendor.vendor_id}`,
31
- job: {
32
- checkoutSessionId: checkoutSession.id,
33
- vendorId: vendor.vendor_id,
34
- },
28
+ if (vendorInfo?.length) {
29
+ const dispatch = () => {
30
+ for (const vendor of vendorInfo) {
31
+ if (vendor.status === 'sent') {
32
+ vendorStatusCheckQueue.push({
33
+ id: `vendor-status-check-${checkoutSession.id}-${vendor.vendor_id}`,
34
+ job: {
35
+ checkoutSessionId: checkoutSession.id,
36
+ vendorId: vendor.vendor_id,
37
+ },
38
+ });
39
+ }
40
+ }
41
+ };
42
+
43
+ try {
44
+ // eslint-disable-next-line no-await-in-loop
45
+ await (checkoutSession.instance_did ? withTenant(checkoutSession.instance_did, dispatch) : dispatch());
46
+ } catch (error) {
47
+ logger.error('startVendorStatusCheckSchedule: enqueue failed', {
48
+ checkoutSessionId: checkoutSession.id,
49
+ error,
35
50
  });
36
51
  }
37
52
  }
@@ -136,7 +136,7 @@ export default {
136
136
  const txHash = await client.sendTransferV3Tx(
137
137
  // @ts-ignore
138
138
  { tx, wallet: fromAddress(userDid) },
139
- await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
139
+ await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
140
140
  );
141
141
  await afterTxExecution({ tx_hash: txHash, payer: userDid, type: 'transfer' });
142
142
 
@@ -158,7 +158,7 @@ export default {
158
158
  const txHash = await client.sendTransferV3Tx(
159
159
  // @ts-ignore
160
160
  { tx, wallet: fromAddress(userDid) },
161
- await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
161
+ await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
162
162
  );
163
163
 
164
164
  await afterTxExecution({
@@ -145,7 +145,7 @@ export default {
145
145
  const txHash = await client.sendTransferV3Tx(
146
146
  // @ts-ignore
147
147
  { tx, wallet: fromAddress(userDid) },
148
- await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
148
+ await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
149
149
  );
150
150
 
151
151
  const quoteValidation = await validateQuoteForPayment({
@@ -201,7 +201,7 @@ export default {
201
201
  const txHash = await client.sendTransferV3Tx(
202
202
  // @ts-ignore
203
203
  { tx, wallet: fromAddress(userDid) },
204
- await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
204
+ await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
205
205
  );
206
206
 
207
207
  await afterTxExecution({
@@ -128,7 +128,7 @@ export default {
128
128
  const txHash = await client.sendTransferV3Tx(
129
129
  // @ts-ignore
130
130
  { tx, wallet: fromAddress(userDid) },
131
- await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
131
+ await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
132
132
  );
133
133
 
134
134
  logger.info('Recharge successful', {
@@ -1573,7 +1573,7 @@ async function executeSingleTransaction(
1573
1573
  const { buffer } = await client[`encode${type}Tx`]({ tx });
1574
1574
  return client[`send${type}Tx`](
1575
1575
  { tx, wallet: fromPublicKey(userPk, toTypeInfo(userDid)) },
1576
- await getGasPayerExtra(buffer, gasPayerHeaders)
1576
+ await getGasPayerExtra(buffer, gasPayerHeaders, client)
1577
1577
  );
1578
1578
  }
1579
1579
 
@@ -0,0 +1,65 @@
1
+ // On-demand, idempotent per-tenant bootstrap — mounted at /api/bootstrap.
2
+ //
3
+ // blocklet-server runs `bootstrapTenant` automatically at startup (service.ts
4
+ // start → single-mode default tenant, or multi-mode via the listInstanceDids
5
+ // hook). The arc-node daemon and the CF worker do NOT: they only wire the
6
+ // lightweight `provisionTenant` (DB seed) into the request path and never call
7
+ // `bootstrapTenant`, so the heavy per-tenant integrations — chiefly
8
+ // `ensureStakedForGas` (declare + stake the app wallet for gas) — never run, and
9
+ // the app can't sponsor on-chain gas.
10
+ //
11
+ // This route exposes the SAME idempotent bootstrap so ANY runtime can trigger it
12
+ // on demand (a one-off curl, an ops cron, an admin button) WITHOUT putting it on
13
+ // the startup path. Every step is idempotent (ensureStakedForGas skips when
14
+ // already staked or under-funded), so repeated calls are safe.
15
+ import { Hono } from 'hono';
16
+
17
+ import { authenticate } from '../../middlewares/hono/security';
18
+ import { getInstanceDid } from '../../libs/context';
19
+ import { wallet } from '../../libs/auth';
20
+ import { hasStakedForGas } from '../../integrations/arcblock/stake';
21
+ import { PaymentMethod } from '../../store/models';
22
+ import logger from '../../libs/logger';
23
+
24
+ const app = new Hono();
25
+
26
+ // Triggers an on-chain stake (spends gas tokens) → restrict to owner/admin or a
27
+ // signed component call. Relax only if a runtime needs to drive it unauthenticated.
28
+ const authAdmin = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'] });
29
+
30
+ app.get('/', authAdmin, async (c) => {
31
+ const instanceDid = getInstanceDid();
32
+ try {
33
+ // The SAME idempotent per-tenant bootstrap the startup path runs on
34
+ // blocklet-server. Lazy require: a top-level import would close a load-time
35
+ // cycle (service.ts → routes/hono → here). bootstrapTenant re-enters
36
+ // withTenant + warmTenantIdentity, so calling it from the request context is safe.
37
+ // eslint-disable-next-line global-require
38
+ const { bootstrapTenant } = require('../../service');
39
+ await bootstrapTenant(instanceDid);
40
+
41
+ // Report the resulting on-chain status per arcblock method (idempotent read)
42
+ // so the caller can verify the stake actually landed.
43
+ const methods = await PaymentMethod.findAll({ where: { type: 'arcblock', active: true } });
44
+ const chains = await Promise.all(
45
+ methods.map(async (method) => {
46
+ const host = method.settings?.arcblock?.api_host;
47
+ try {
48
+ const client = method.getOcapClient();
49
+ const { state } = await client.getAccountState({ address: wallet.address });
50
+ const stakedForGas = await hasStakedForGas(method);
51
+ return { method: method.name, host, declared: !!state, balance: state?.balance ?? null, stakedForGas };
52
+ } catch (err) {
53
+ return { method: method.name, host, error: (err as Error).message };
54
+ }
55
+ })
56
+ );
57
+
58
+ return c.json({ ok: true, instanceDid, appWallet: wallet.address, chains });
59
+ } catch (err) {
60
+ logger.error('bootstrap endpoint failed', { instanceDid, error: err });
61
+ return c.json({ ok: false, instanceDid, error: (err as Error).message }, 500);
62
+ }
63
+ });
64
+
65
+ export default app;
@@ -48,6 +48,7 @@ import vendor from './vendor';
48
48
  import webhookAttempts from './webhook-attempts';
49
49
  import webhookEndpoints from './webhook-endpoints';
50
50
  import archive from './archive';
51
+ import bootstrap from './bootstrap';
51
52
 
52
53
  export function mountMigratedResources(native: Hono, opts: { appShell?: MiddlewareHandler[] } = {}): void {
53
54
  // The node host passes its full app-shell pipeline (cors/xss/csrf/cdn/i18n/
@@ -110,6 +111,11 @@ export function mountMigratedResources(native: Hono, opts: { appShell?: Middlewa
110
111
  g('/api/webhook-attempts', webhookAttempts);
111
112
  g('/api/webhook-endpoints', webhookEndpoints);
112
113
  g('/api/archive', archive);
114
+
115
+ // On-demand idempotent per-tenant bootstrap (declare/stake the app wallet for
116
+ // gas). arc-node/CF never run the startup bootstrapTenant, so this is the
117
+ // runtime-agnostic trigger — see routes/hono/bootstrap.ts.
118
+ g('/api/bootstrap', bootstrap);
113
119
  }
114
120
 
115
121
  export default mountMigratedResources;
@@ -402,7 +402,7 @@ let listInstanceDidsHook: (() => Promise<string[]> | string[]) | undefined;
402
402
  * tenancy.listInstanceDids). Single mode bootstraps the default tenant from
403
403
  * start() automatically (original behavior preserved).
404
404
  */
405
- async function bootstrapTenant(instanceDid: string): Promise<void> {
405
+ export async function bootstrapTenant(instanceDid: string): Promise<void> {
406
406
  if (!instanceDid || typeof instanceDid !== 'string') {
407
407
  throw new TenantError(TENANT_CONTEXT_MISSING, 'bootstrapTenant requires an instanceDid');
408
408
  }
@@ -518,6 +518,12 @@ async function startBackgroundServices(): Promise<void> {
518
518
  }
519
519
  servicesStarted = true;
520
520
 
521
+ // Disable @blocklet/sdk host transports (notification + eventbus) off Blocklet
522
+ // Server — arc-node / CF have no BLOCKLET_APP_ID, so sendToUser/publish would
523
+ // crash/spam. Gated on isBlockletServer(); runs in EVERY runtime via
524
+ // lifecycle.start(); no-op on Blocklet Server.
525
+ require('./libs/notification').ensureBlockletSdkTransportGuard();
526
+
521
527
  const crons = require('./crons/index').default;
522
528
  const { initResourceHandler } = require('./integrations/blocklet/resource');
523
529
  const { initUserHandler } = require('./integrations/blocklet/user');
@@ -547,7 +553,14 @@ async function startBackgroundServices(): Promise<void> {
547
553
  ['discount status', require('./queues/discount-status').startDiscountStatusQueue],
548
554
  ];
549
555
  for (const [name, start] of starters) {
550
- Promise.resolve(start()).then(() => logger.info(`${name} queue started`));
556
+ // .catch() is load-bearing: a starter that does a cross-tenant systemFindAll
557
+ // then enqueues without a tenant context rejects in multi mode (injectJobTenant
558
+ // -> getInstanceDid -> TENANT_CONTEXT_MISSING). Fire-and-forget without .catch()
559
+ // turns that into an unhandledRejection -> FATAL on boot. Degrade + log instead;
560
+ // per-starter tenant correctness is handled inside each starter (L2).
561
+ Promise.resolve(start())
562
+ .then(() => logger.info(`${name} queue started`))
563
+ .catch((error: unknown) => logger.error(`${name} queue failed to start`, { error }));
551
564
  }
552
565
 
553
566
  // cron + global handlers (tenant-agnostic; runOnInit is skipped in multi mode)
@@ -0,0 +1,74 @@
1
+ // getGasPayerExtra gas-payer selection (libs/payment.ts).
2
+ //
3
+ // Policy (decided 2026-06): when the app has staked for gas it is the deliberate
4
+ // sponsor, so its OWN gas-payer header must win over any wallet-provided one —
5
+ // otherwise ArcWallet's header (attached to every wallet tx) always wins, the
6
+ // app's stake is never used, and the chain falls back to charging `from`
7
+ // (GAS_PAYER_NOT_ON_CHAIN). These tests pin that gate. The heavy payment.ts deps
8
+ // are mocked so the suite stays DB-free.
9
+ const getAppStakeAddressWithFallback = jest.fn();
10
+ jest.mock('../../src/libs/wallet-migration', () => ({
11
+ getAppStakeAddressWithFallback,
12
+ getDelegationAddressWithFallback: jest.fn(),
13
+ backfillDelegationAddress: jest.fn(),
14
+ getMigratedFromList: jest.fn(),
15
+ }));
16
+
17
+ const wallet = { signJWT: jest.fn(async () => 'APP_SIG'), publicKey: 'APP_PK', address: 'zApp' };
18
+ jest.mock('../../src/libs/auth', () => ({
19
+ wallet,
20
+ ethWallet: {},
21
+ blocklet: {},
22
+ getVaultAddress: jest.fn(),
23
+ }));
24
+
25
+ jest.mock('@ocap/mcrypto', () => ({
26
+ ...jest.requireActual('@ocap/mcrypto'),
27
+ toTxHash: () => 'TXHASH',
28
+ }));
29
+
30
+ import { getGasPayerExtra } from '../../src/libs/payment';
31
+
32
+ const buffer = Buffer.from('tx-bytes');
33
+ const walletHeaders = { 'x-gas-payer-sig': 'WALLET_SIG', 'x-gas-payer-pk': 'WALLET_PK' };
34
+ const fakeClient = {} as any;
35
+
36
+ beforeEach(() => {
37
+ getAppStakeAddressWithFallback.mockReset();
38
+ wallet.signJWT.mockClear();
39
+ });
40
+
41
+ describe('getGasPayerExtra — app-staked gas-payer preference', () => {
42
+ it('no client: wallet header wins (prior behavior)', async () => {
43
+ const out = await getGasPayerExtra(buffer, walletHeaders);
44
+ expect(out).toEqual({ headers: walletHeaders });
45
+ expect(getAppStakeAddressWithFallback).not.toHaveBeenCalled();
46
+ expect(wallet.signJWT).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it('client + app NOT staked: wallet header still wins', async () => {
50
+ getAppStakeAddressWithFallback.mockResolvedValue(null);
51
+ const out = await getGasPayerExtra(buffer, walletHeaders, fakeClient);
52
+ expect(out).toEqual({ headers: walletHeaders });
53
+ expect(wallet.signJWT).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it('client + app STAKED: app self-sponsors, ignoring the wallet header', async () => {
57
+ getAppStakeAddressWithFallback.mockResolvedValue({ address: 'zStake', source: 'current' });
58
+ const out = await getGasPayerExtra(buffer, walletHeaders, fakeClient);
59
+ expect(out).toEqual({ headers: { 'x-gas-payer-sig': 'APP_SIG', 'x-gas-payer-pk': 'APP_PK' } });
60
+ expect(wallet.signJWT).toHaveBeenCalledWith({ txHash: 'TXHASH' });
61
+ });
62
+
63
+ it('no wallet header: app self-signs regardless', async () => {
64
+ const out = await getGasPayerExtra(buffer);
65
+ expect(out).toEqual({ headers: { 'x-gas-payer-sig': 'APP_SIG', 'x-gas-payer-pk': 'APP_PK' } });
66
+ });
67
+
68
+ it('stake check error: keeps the wallet header (best-effort, never blocks a payment)', async () => {
69
+ getAppStakeAddressWithFallback.mockRejectedValue(new Error('chain timeout'));
70
+ const out = await getGasPayerExtra(buffer, walletHeaders, fakeClient);
71
+ expect(out).toEqual({ headers: walletHeaders });
72
+ expect(wallet.signJWT).not.toHaveBeenCalled();
73
+ });
74
+ });
@@ -0,0 +1,61 @@
1
+ // ensureBlockletSdkTransportGuard (libs/notification/index.ts).
2
+ //
3
+ // @blocklet/sdk's notification (sendToUser/sendToRelay/sendToMail) and eventbus
4
+ // (publish/subscribe/unsubscribe) throw "BLOCKLET_APP_ID does not exist in
5
+ // environments" off Blocklet Server (arc-node / CF). The guard no-ops both SDK
6
+ // transports there — at the module level, so app-side logic still runs and only
7
+ // the send/publish is skipped — and leaves Blocklet Server untouched. Gated on the
8
+ // single reusable predicate isBlockletServer() (= BLOCKLET_APP_ID present).
9
+ describe('ensureBlockletSdkTransportGuard', () => {
10
+ const ORIG = process.env.BLOCKLET_APP_ID;
11
+
12
+ afterEach(() => {
13
+ if (ORIG === undefined) delete process.env.BLOCKLET_APP_ID;
14
+ else process.env.BLOCKLET_APP_ID = ORIG;
15
+ jest.resetModules();
16
+ });
17
+
18
+ // Fresh module registry per case so the once-only guard flag resets; fresh mock
19
+ // SDK modules are patched in isolation (no global @blocklet/sdk pollution).
20
+ function loadAndGuard(): { notification: any; eventbus: any } {
21
+ let notification: any;
22
+ let eventbus: any;
23
+ jest.isolateModules(() => {
24
+ const notif = { sendToUser: 'REAL', sendToRelay: 'REAL', sendToMail: 'REAL' };
25
+ notification = { ...notif, default: { ...notif }, getSender: () => ({}) };
26
+ const evt = { publish: 'REAL', subscribe: 'REAL', unsubscribe: 'REAL' };
27
+ eventbus = { ...evt, default: { ...evt } };
28
+ jest.doMock('@blocklet/sdk/service/notification', () => notification);
29
+ jest.doMock('@blocklet/sdk/service/eventbus', () => eventbus);
30
+ // eslint-disable-next-line global-require
31
+ require('../../src/libs/notification').ensureBlockletSdkTransportGuard();
32
+ });
33
+ return { notification, eventbus };
34
+ }
35
+
36
+ it('off Blocklet Server (no BLOCKLET_APP_ID): no-ops notification + eventbus on module + default', async () => {
37
+ delete process.env.BLOCKLET_APP_ID;
38
+ const { notification, eventbus } = loadAndGuard();
39
+ for (const target of [notification, notification.default]) {
40
+ expect(target.sendToUser).not.toBe('REAL');
41
+ expect(target.sendToRelay).not.toBe('REAL');
42
+ expect(target.sendToMail).not.toBe('REAL');
43
+ await expect(target.sendToUser('zUser', { title: 't' })).resolves.toBeUndefined();
44
+ }
45
+ for (const target of [eventbus, eventbus.default]) {
46
+ expect(target.publish).not.toBe('REAL');
47
+ expect(target.subscribe).not.toBe('REAL');
48
+ expect(target.unsubscribe).not.toBe('REAL');
49
+ await expect(target.publish('invoice.paid', {})).resolves.toBeUndefined();
50
+ }
51
+ });
52
+
53
+ it('on Blocklet Server (BLOCKLET_APP_ID set): leaves both transports intact', () => {
54
+ process.env.BLOCKLET_APP_ID = 'z_app_pid';
55
+ const { notification, eventbus } = loadAndGuard();
56
+ expect(notification.sendToUser).toBe('REAL');
57
+ expect(notification.default.sendToUser).toBe('REAL');
58
+ expect(eventbus.publish).toBe('REAL');
59
+ expect(eventbus.default.publish).toBe('REAL');
60
+ });
61
+ });
@@ -4,7 +4,7 @@
4
4
  // header (the fork reads c.req.header('host') — never a proxy header).
5
5
  import { Hono } from 'hono';
6
6
  import { ensureI18n, contextMiddleware } from '../../../src/middlewares/hono/context';
7
- import { getInstanceDid } from '../../../src/libs/context';
7
+ import { context, getInstanceDid } from '../../../src/libs/context';
8
8
  import { getDefaultInstanceDid } from '../../../src/libs/tenant';
9
9
  import {
10
10
  setIdentityDriver,
@@ -111,3 +111,31 @@ describe('hono contextMiddleware — multi mode (Host→tenant, fail-closed)', (
111
111
  expect(handlerRuns).toBe(0);
112
112
  });
113
113
  });
114
+
115
+ describe('hono contextMiddleware — pre-scoped request (CF worker withTenant)', () => {
116
+ // The CF worker pre-resolves Host→tenant and wraps the core in withTenant, then
117
+ // sets its identity driver's resolveInstanceDidForHost to null on purpose. A host
118
+ // that the driver CANNOT resolve must still succeed when a tenant context is
119
+ // already established — otherwise native routes (/api/settings) 400 with
120
+ // TENANT_HOST_UNRESOLVED while connect routes (which already honor the context)
121
+ // work. This pins the native+connect parity.
122
+ const nullDriver: IdentityDriver = { resolveInstanceDidForHost: () => null };
123
+
124
+ beforeEach(() => {
125
+ process.env.PAYMENT_TENANT_MODE = 'multi';
126
+ setIdentityDriver(nullDriver);
127
+ });
128
+
129
+ it('honors the pre-established tenant context instead of re-resolving the host', async () => {
130
+ const app = buildApp();
131
+ const res = await context.withTenant(TENANT_A, () => get(app, 'todo.localhost:8787'));
132
+ expect(res.status).toBe(200);
133
+ expect((await res.json()).tenant).toBe(TENANT_A);
134
+ });
135
+
136
+ it('still fails closed 400 when NO context is pre-established and the host is unresolvable', async () => {
137
+ const r = await get(buildApp(), 'todo.localhost:8787');
138
+ expect(r.status).toBe(400);
139
+ expect((await r.json()).error.code).toBe('TENANT_HOST_UNRESOLVED');
140
+ });
141
+ });
@@ -0,0 +1,171 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Sequelize } from 'sequelize';
5
+ import { SequelizeStorage, Umzug } from 'umzug';
6
+
7
+ import { withTenant } from '../../src/libs/context';
8
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
9
+
10
+ // Layer-2 (build-phases) regression coverage for the boot/cron starter dispatch
11
+ // fix (commit "fix(payment-core): tenant-context safety for boot/cron queue
12
+ // dispatch"). Mirrors event-tenant.spec.ts's startEventQueue case for the FATAL
13
+ // site: startVendorCommissionQueue used forEach(async) + a push outside
14
+ // withTenant, so in multi mode injectJobTenant -> getInstanceDid threw
15
+ // TENANT_CONTEXT_MISSING as an unhandled rejection -> daemon crash on boot.
16
+
17
+ jest.mock('../../src/libs/logger', () => ({
18
+ __esModule: true,
19
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
20
+ }));
21
+
22
+ // neutralize the queue engine (real createQueue scans the jobs table on import,
23
+ // racing the temp DB lifecycle) — same shim event-tenant.spec.ts uses.
24
+ jest.mock('../../src/libs/queue', () => ({
25
+ __esModule: true,
26
+ default: () => ({
27
+ push: jest.fn(),
28
+ pushAndWait: jest.fn(),
29
+ cancel: jest.fn(),
30
+ on: jest.fn(),
31
+ get: jest.fn().mockResolvedValue(null),
32
+ }),
33
+ assertJobObjectTenant: jest.fn(),
34
+ }));
35
+
36
+ const STORE_DIR = path.join(__dirname, '../../src/store');
37
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'starter-dispatch-'));
38
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
39
+ const umzug = new Umzug({
40
+ migrations: {
41
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
42
+ resolve: ({ name, path: p, context }) => {
43
+ // eslint-disable-next-line import/no-dynamic-require, global-require
44
+ const migration = require(p!);
45
+ return {
46
+ name: name.replace(/\.ts$/, '.js'),
47
+ up: () => migration.up({ context }),
48
+ down: () => migration.down({ context }),
49
+ };
50
+ },
51
+ },
52
+ context: sequelize.getQueryInterface(),
53
+ storage: new SequelizeStorage({ sequelize }),
54
+ logger: undefined,
55
+ });
56
+
57
+ let models: any;
58
+ let startVendorCommissionQueue: any;
59
+ let vendorCommissionQueue: any;
60
+ let getInstanceDid: any;
61
+ let logger: any;
62
+
63
+ beforeAll(async () => {
64
+ await umzug.up();
65
+ // eslint-disable-next-line global-require
66
+ models = require('../../src/store/models');
67
+ models.initialize(sequelize);
68
+ // eslint-disable-next-line global-require
69
+ ({ startVendorCommissionQueue, vendorCommissionQueue } = require('../../src/queues/vendors/commission'));
70
+ // eslint-disable-next-line global-require
71
+ ({ getInstanceDid } = require('../../src/libs/context'));
72
+ // eslint-disable-next-line global-require
73
+ logger = require('../../src/libs/logger').default;
74
+ }, 120000);
75
+
76
+ afterAll(async () => {
77
+ await sequelize.close();
78
+ fs.rmSync(dir, { recursive: true, force: true });
79
+ });
80
+
81
+ const seedPI = (tenant: string, invoiceId: string) =>
82
+ withTenant(tenant, () =>
83
+ models.PaymentIntent.create({
84
+ instance_did: tenant,
85
+ livemode: false,
86
+ amount: '100',
87
+ currency_id: 'cur_test',
88
+ payment_method_id: 'pm_test',
89
+ status: 'requires_capture',
90
+ capture_method: 'automatic',
91
+ confirmation_method: 'automatic',
92
+ invoice_id: invoiceId,
93
+ })
94
+ );
95
+
96
+ const ORIGINAL_MODE = process.env.PAYMENT_TENANT_MODE;
97
+ beforeEach(async () => {
98
+ jest.clearAllMocks();
99
+ await sequelize.query('DELETE FROM payment_intents');
100
+ });
101
+ afterEach(() => {
102
+ if (ORIGINAL_MODE === undefined) delete process.env.PAYMENT_TENANT_MODE;
103
+ else process.env.PAYMENT_TENANT_MODE = ORIGINAL_MODE;
104
+ });
105
+
106
+ describe('startVendorCommissionQueue — per-tenant boot dispatch (multi-tenant)', () => {
107
+ it('multi mode: each tenant intent is enqueued INSIDE its own tenant (no crash, correct stamp)', async () => {
108
+ process.env.PAYMENT_TENANT_MODE = 'multi';
109
+ await seedPI(TENANT_A, 'inv_a');
110
+ await seedPI(TENANT_B, 'inv_b');
111
+
112
+ // the push captures the tenant injectJobTenant would read (getInstanceDid).
113
+ // would THROW in multi mode if the push ran outside withTenant (the old bug).
114
+ const stamps: Record<string, string> = {};
115
+ let threw = false;
116
+ (vendorCommissionQueue.push as jest.Mock).mockImplementation((arg: any) => {
117
+ try {
118
+ stamps[arg.id] = getInstanceDid();
119
+ } catch {
120
+ threw = true;
121
+ }
122
+ });
123
+
124
+ await expect(startVendorCommissionQueue()).resolves.toBeUndefined();
125
+
126
+ expect(threw).toBe(false);
127
+ expect(vendorCommissionQueue.push).toHaveBeenCalledTimes(2);
128
+ expect(stamps['vendor-commission-inv_a']).toBe(TENANT_A);
129
+ expect(stamps['vendor-commission-inv_b']).toBe(TENANT_B);
130
+ });
131
+
132
+ it('multi mode: a tenant-less intent is isolated by the per-row catch — pass does not crash', async () => {
133
+ process.env.PAYMENT_TENANT_MODE = 'multi';
134
+ const pi = await seedPI(TENANT_A, 'inv_a');
135
+ // null the tenant directly (bypass the scoped writer)
136
+ await sequelize.query('UPDATE payment_intents SET instance_did = NULL WHERE id = $id', {
137
+ bind: { id: pi.id },
138
+ });
139
+
140
+ // a tenant-less row runs dispatch() with NO withTenant; the real push's
141
+ // injectJobTenant -> getInstanceDid throws in multi mode. Simulate that here.
142
+ (vendorCommissionQueue.push as jest.Mock).mockImplementation(() => {
143
+ getInstanceDid();
144
+ });
145
+
146
+ // the per-row try/catch must absorb it — the starter resolves, never rejects.
147
+ await expect(startVendorCommissionQueue()).resolves.toBeUndefined();
148
+ expect(logger.error).toHaveBeenCalledWith(
149
+ 'startVendorCommissionQueue: re-queue failed',
150
+ expect.objectContaining({ invoiceId: 'inv_a' })
151
+ );
152
+ });
153
+
154
+ it('single mode: enqueues without a tenant context (default tenant), no throw', async () => {
155
+ delete process.env.PAYMENT_TENANT_MODE; // single
156
+ await seedPI(TENANT_A, 'inv_a');
157
+
158
+ let threw = false;
159
+ (vendorCommissionQueue.push as jest.Mock).mockImplementation(() => {
160
+ try {
161
+ getInstanceDid();
162
+ } catch {
163
+ threw = true;
164
+ }
165
+ });
166
+
167
+ await expect(startVendorCommissionQueue()).resolves.toBeUndefined();
168
+ expect(threw).toBe(false);
169
+ expect(vendorCommissionQueue.push).toHaveBeenCalledTimes(1);
170
+ });
171
+ });
@@ -0,0 +1,87 @@
1
+ // Unit test for the on-demand bootstrap endpoint (routes/hono/bootstrap.ts).
2
+ // The handler is isolated from its heavy deps: bootstrapTenant (service), the
3
+ // arcblock stake helpers, the models, the business wallet, and tenant context are
4
+ // mocked, and `authenticate` is stubbed to a pass-through (auth gating itself is
5
+ // covered by middlewares/hono/security.spec.ts). This keeps the test DB-free while
6
+ // pinning the handler contract: it runs the idempotent bootstrap for the current
7
+ // tenant and reports per-chain stake status.
8
+ const bootstrapTenant = jest.fn(async () => {});
9
+ jest.mock('../../../src/service', () => ({ bootstrapTenant }));
10
+
11
+ const findAll = jest.fn(async () => [] as any[]);
12
+ jest.mock('../../../src/store/models', () => ({ PaymentMethod: { findAll } }));
13
+
14
+ const hasStakedForGas = jest.fn(async () => false);
15
+ jest.mock('../../../src/integrations/arcblock/stake', () => ({ hasStakedForGas }));
16
+
17
+ jest.mock('../../../src/libs/auth', () => ({ wallet: { address: 'zAppWalletTest' } }));
18
+ jest.mock('../../../src/libs/context', () => ({ getInstanceDid: () => 'did:abt:zTenantTest' }));
19
+ jest.mock('../../../src/middlewares/hono/security', () => ({
20
+ authenticate: () => (_c: any, next: any) => next(),
21
+ }));
22
+
23
+ import { Hono } from 'hono';
24
+ import bootstrap from '../../../src/routes/hono/bootstrap';
25
+
26
+ const app = new Hono();
27
+ app.route('/api/bootstrap', bootstrap);
28
+ const get = () => app.fetch(new Request('http://app.local/api/bootstrap', { headers: { host: 'app.local' } }));
29
+
30
+ beforeEach(() => {
31
+ bootstrapTenant.mockClear();
32
+ findAll.mockReset().mockResolvedValue([]);
33
+ hasStakedForGas.mockReset().mockResolvedValue(false);
34
+ });
35
+
36
+ describe('GET /api/bootstrap — on-demand idempotent bootstrap', () => {
37
+ it('runs bootstrapTenant for the current tenant and returns a status report', async () => {
38
+ const r = await get();
39
+ expect(r.status).toBe(200);
40
+ const body = await r.json();
41
+ expect(body).toMatchObject({
42
+ ok: true,
43
+ instanceDid: 'did:abt:zTenantTest',
44
+ appWallet: 'zAppWalletTest',
45
+ chains: [],
46
+ });
47
+ expect(bootstrapTenant).toHaveBeenCalledWith('did:abt:zTenantTest');
48
+ expect(bootstrapTenant).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it('is idempotent — repeated calls re-run the bootstrap and keep succeeding', async () => {
52
+ await get();
53
+ const r2 = await get();
54
+ expect(r2.status).toBe(200);
55
+ expect((await r2.json()).ok).toBe(true);
56
+ expect(bootstrapTenant).toHaveBeenCalledTimes(2);
57
+ });
58
+
59
+ it('reports declared/balance/stakedForGas per arcblock method', async () => {
60
+ const getAccountState = jest.fn(async () => ({ state: { balance: '100' } }));
61
+ findAll.mockResolvedValueOnce([
62
+ {
63
+ name: 'ArcBlock Beta',
64
+ settings: { arcblock: { api_host: 'https://beta.abtnetwork.io/api/' } },
65
+ getOcapClient: () => ({ getAccountState }),
66
+ } as any,
67
+ ]);
68
+ hasStakedForGas.mockResolvedValueOnce(true);
69
+
70
+ const r = await get();
71
+ const body = await r.json();
72
+ expect(body.chains[0]).toMatchObject({
73
+ method: 'ArcBlock Beta',
74
+ host: 'https://beta.abtnetwork.io/api/',
75
+ declared: true,
76
+ balance: '100',
77
+ stakedForGas: true,
78
+ });
79
+ });
80
+
81
+ it('fails closed with 500 + message when the bootstrap throws', async () => {
82
+ bootstrapTenant.mockRejectedValueOnce(new Error('chain down'));
83
+ const r = await get();
84
+ expect(r.status).toBe(500);
85
+ expect(await r.json()).toMatchObject({ ok: false, instanceDid: 'did:abt:zTenantTest', error: 'chain down' });
86
+ });
87
+ });
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.29.4
17
+ version: 1.29.6
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -39,6 +39,7 @@ import crons from '../api/src/crons/index';
39
39
  import { createEmbeddedPaymentService } from '../api/src/service';
40
40
  import type { EmbeddedPaymentService } from '../api/src/service';
41
41
  import { context as requestContext } from '../api/src/libs/context';
42
+ import { warmTenantIdentity } from '../api/src/libs/did-connect/tenant-identity';
42
43
  import {
43
44
  createD1DbDriver,
44
45
  createD1LocksDriver,
@@ -231,7 +232,19 @@ export function buildFetch(deps: FetchDeps) {
231
232
  // the core's connect/context middleware then resolves via the identity driver,
232
233
  // which returns null → TENANT_HOST_UNRESOLVED 4xx (multi-mode fail-closed). The
233
234
  // bare health route still answers without a tenant.
234
- const run = () => deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
235
+ //
236
+ // Warm the tenant identity (app:ek + business wallets) INSIDE the withTenant
237
+ // scope before any route handler runs. The CF resource pipeline is the LITE
238
+ // app-shell (no contextMiddleware), so unlike the node host nothing else warms
239
+ // it — and wallet field access (e.g. `wallet.publicKey` in GET /api/vendors) is
240
+ // a SYNCHRONOUS getCachedTenantIdentity() that fails-closed on a cold cache.
241
+ // Best-effort (warmTenantIdentity swallows errors); cached 5 min per tenant so
242
+ // only the first request per window pays the RPC. Queue jobs / bootstrapTenant
243
+ // already warm on their own paths.
244
+ const run = async () => {
245
+ if (instanceDid) await warmTenantIdentity(instanceDid);
246
+ return deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
247
+ };
235
248
  const res = instanceDid ? await requestContext.withTenant(instanceDid, run) : await run();
236
249
 
237
250
  await deps.flush(); // drain workerd deferred queue work before responding
@@ -288,8 +288,17 @@ const alias = {
288
288
  'crypto-js/enc-utf8': s('shims/noop.ts'),
289
289
  'crypto-js/enc-utf16': s('shims/noop.ts'),
290
290
 
291
- // Force ESM-only to avoid CJS+ESM double-bundling
291
+ // Force ESM-only to avoid CJS+ESM double-bundling. These packages ship an
292
+ // exports map with separate import/require conditions, so esbuild picks the
293
+ // condition by call kind — and the payment graph reaches them via BOTH `import`
294
+ // (business code) and `require` (source require('ethers') + CJS @ocap/* deps),
295
+ // pulling two full copies. Pinning each bare specifier straight at its ESM
296
+ // entry (bypassing the exports map, like require.resolve can't — see valibot)
297
+ // collapses the duplicate. Measured on dist/cf.js: 1236KB → 1028KB gzip (-17%).
292
298
  valibot: path.resolve(cfDir, '../../..', 'node_modules/valibot/dist/index.mjs'), // 396KB → ~200KB
299
+ ethers: path.resolve(cfDir, '../../..', 'node_modules/ethers/lib.esm/index.js'), // 767KB(cjs406+esm267) → 299KB; also drops the lib.commonjs/wordlists the esm-only dropEthersWordlists plugin never matched
300
+ 'aes-js': path.resolve(cfDir, '../../..', 'node_modules/aes-js/lib.esm/index.js'), // 90KB → 42KB (CJS copy pulled by ethers/json-keystore + @ocap)
301
+ '@adraffy/ens-normalize': path.resolve(cfDir, '../../..', 'node_modules/@adraffy/ens-normalize/dist/index.mjs'), // 73KB → 36KB (CJS copy pulled by ethers/hash/namehash)
293
302
  // CF Workers: noop modules not useful in Workers environment
294
303
  phoenix: s('shims/noop.ts'), // 36KB — Phoenix channels, only used by @arcblock/ws
295
304
  numbro: s('shims/noop.ts'), // 49KB — number formatting, replaced inline in util.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.29.4",
3
+ "version": "1.29.6",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "prelint": "npm run types",
@@ -49,7 +49,7 @@
49
49
  "@abtnode/cron": "^1.17.13-beta-20260613-094425-b81920c8",
50
50
  "@apple/app-store-server-library": "^3.1.0",
51
51
  "@arcblock/did": "^1.30.24",
52
- "@arcblock/did-connect-js": "^4.0.6",
52
+ "@arcblock/did-connect-js": "^4.0.7",
53
53
  "@arcblock/did-connect-react": "^3.5.4",
54
54
  "@arcblock/did-connect-storage-nedb": "^1.8.0",
55
55
  "@arcblock/did-util": "^1.30.24",
@@ -61,9 +61,9 @@
61
61
  "@blocklet/error": "^0.3.5",
62
62
  "@blocklet/js-sdk": "^1.17.13-beta-20260613-094425-b81920c8",
63
63
  "@blocklet/logger": "^1.17.13-beta-20260613-094425-b81920c8",
64
- "@blocklet/payment-broker-client": "1.29.4",
65
- "@blocklet/payment-react": "1.29.4",
66
- "@blocklet/payment-vendor": "1.29.4",
64
+ "@blocklet/payment-broker-client": "1.29.6",
65
+ "@blocklet/payment-react": "1.29.6",
66
+ "@blocklet/payment-vendor": "1.29.6",
67
67
  "@blocklet/sdk": "^1.17.13-beta-20260613-094425-b81920c8",
68
68
  "@blocklet/ui-react": "^3.5.4",
69
69
  "@blocklet/uploader": "^0.3.20",
@@ -135,7 +135,7 @@
135
135
  "devDependencies": {
136
136
  "@abtnode/types": "^1.17.13-beta-20260613-094425-b81920c8",
137
137
  "@arcblock/eslint-config-ts": "^0.3.3",
138
- "@blocklet/payment-types": "1.29.4",
138
+ "@blocklet/payment-types": "1.29.6",
139
139
  "@types/connect": "^3.4.38",
140
140
  "@types/debug": "^4.1.12",
141
141
  "@types/dotenv-flow": "^3.3.3",
@@ -183,5 +183,5 @@
183
183
  "parser": "typescript"
184
184
  }
185
185
  },
186
- "gitHead": "ffde8059827d7688a7e5a1e65830912e9093bb7c"
186
+ "gitHead": "ee97756d3e9ba56def933580a6cb6bb93d97b55c"
187
187
  }