payment-kit 1.22.32 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/api/src/index.ts +4 -0
  2. package/api/src/integrations/arcblock/token.ts +599 -0
  3. package/api/src/libs/credit-grant.ts +7 -6
  4. package/api/src/libs/util.ts +34 -0
  5. package/api/src/queues/credit-consume.ts +29 -4
  6. package/api/src/queues/credit-grant.ts +245 -50
  7. package/api/src/queues/credit-reconciliation.ts +253 -0
  8. package/api/src/queues/refund.ts +263 -30
  9. package/api/src/queues/token-transfer.ts +331 -0
  10. package/api/src/routes/checkout-sessions.ts +94 -29
  11. package/api/src/routes/credit-grants.ts +35 -9
  12. package/api/src/routes/credit-tokens.ts +38 -0
  13. package/api/src/routes/credit-transactions.ts +20 -3
  14. package/api/src/routes/index.ts +2 -0
  15. package/api/src/routes/meter-events.ts +4 -0
  16. package/api/src/routes/meters.ts +32 -10
  17. package/api/src/routes/payment-currencies.ts +103 -0
  18. package/api/src/routes/payment-links.ts +3 -1
  19. package/api/src/routes/products.ts +2 -2
  20. package/api/src/routes/settings.ts +4 -3
  21. package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
  22. package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
  23. package/api/src/store/migrations/20251211-optimize-slow-queries.ts +33 -0
  24. package/api/src/store/models/credit-grant.ts +47 -9
  25. package/api/src/store/models/credit-transaction.ts +18 -1
  26. package/api/src/store/models/index.ts +2 -1
  27. package/api/src/store/models/payment-currency.ts +31 -4
  28. package/api/src/store/models/refund.ts +12 -2
  29. package/api/src/store/models/types.ts +48 -0
  30. package/api/src/store/sequelize.ts +1 -0
  31. package/api/third.d.ts +2 -0
  32. package/blocklet.yml +1 -1
  33. package/package.json +7 -6
  34. package/src/app.tsx +10 -0
  35. package/src/components/customer/credit-overview.tsx +19 -3
  36. package/src/components/meter/form.tsx +191 -18
  37. package/src/components/price/form.tsx +49 -37
  38. package/src/locales/en.tsx +25 -1
  39. package/src/locales/zh.tsx +27 -1
  40. package/src/pages/admin/billing/meters/create.tsx +42 -13
  41. package/src/pages/admin/billing/meters/detail.tsx +56 -5
  42. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +13 -0
  43. package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +324 -0
  44. package/src/pages/admin/customers/index.tsx +5 -0
  45. package/src/pages/customer/credit-grant/detail.tsx +14 -1
  46. package/src/pages/customer/credit-transaction/detail.tsx +289 -0
  47. package/src/pages/customer/invoice/detail.tsx +1 -1
  48. package/src/pages/customer/recharge/subscription.tsx +1 -1
  49. package/src/pages/customer/subscription/detail.tsx +1 -1
package/api/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { initEventBroadcast } from './libs/ws';
27
27
  import { startCheckoutSessionQueue } from './queues/checkout-session';
28
28
  import { startCreditConsumeQueue } from './queues/credit-consume';
29
29
  import { startCreditGrantQueue } from './queues/credit-grant';
30
+ import { startReconciliationQueue } from './queues/credit-reconciliation';
30
31
  import { startDiscountStatusQueue } from './queues/discount-status';
31
32
  import { startEventQueue } from './queues/event';
32
33
  import { startInvoiceQueue } from './queues/invoice';
@@ -36,6 +37,7 @@ import { startPayoutQueue } from './queues/payout';
36
37
  import { startRefundQueue } from './queues/refund';
37
38
  import { startUploadBillingInfoListener } from './queues/space';
38
39
  import { startSubscriptionQueue } from './queues/subscription';
40
+ import { startTokenTransferQueue } from './queues/token-transfer';
39
41
  import { startVendorCommissionQueue } from './queues/vendors/commission';
40
42
  import { startVendorFulfillmentQueue } from './queues/vendors/fulfillment';
41
43
  import { startCoordinatedFulfillmentQueue } from './queues/vendors/fulfillment-coordinator';
@@ -142,6 +144,8 @@ export const server = app.listen(port, (err?: any) => {
142
144
  startRefundQueue().then(() => logger.info('refund queue started'));
143
145
  startCreditConsumeQueue().then(() => logger.info('credit queue started'));
144
146
  startCreditGrantQueue().then(() => logger.info('credit grant queue started'));
147
+ startTokenTransferQueue().then(() => logger.info('token transfer queue started'));
148
+ startReconciliationQueue().then(() => logger.info('credit reconciliation queue started'));
145
149
  startDiscountStatusQueue().then(() => logger.info('discount status queue started'));
146
150
  startUploadBillingInfoListener();
147
151
 
@@ -0,0 +1,599 @@
1
+ import { toStakeAddress, toTokenAddress, toTokenFactoryAddress } from '@arcblock/did-util';
2
+ import { fromPublicKeyHash, toTypeInfo } from '@arcblock/did';
3
+ import stringify from 'json-stable-stringify';
4
+ import { BN, fromTokenToUnit, fromUnitToToken, toBN, toBase58, toBase64 } from '@ocap/util';
5
+ import { types } from '@ocap/mcrypto';
6
+ import { verify as verifyVC } from '@arcblock/vc';
7
+ import { BlockletService } from '@blocklet/sdk/service/blocklet';
8
+
9
+ import { PaymentMethod } from '../../store/models';
10
+ import type { TPaymentCurrency } from '../../store/models';
11
+ import { wallet } from '../../libs/auth';
12
+ import logger from '../../libs/logger';
13
+ import env from '../../libs/env';
14
+ import { sleep } from '../../libs/util';
15
+ import { getGasPayerExtra } from '../../libs/payment';
16
+
17
+ const blockletService = new BlockletService();
18
+
19
+ export function isOnchainCredit(paymentCurrency: TPaymentCurrency) {
20
+ return paymentCurrency.type === 'credit' && !!paymentCurrency.token_config;
21
+ }
22
+
23
+ /**
24
+ * Stake ABT for token creation
25
+ */
26
+ async function stakeForTokenCreation(tokenSymbol: string, client: any) {
27
+ try {
28
+ // ensure context for stake
29
+ await client.getContext();
30
+
31
+ // Get chain configuration
32
+ const { state: forgeState } = await client.getForgeState({});
33
+ const requiredStake = new BN(forgeState.txConfig.txStake.createCreditToken || 0);
34
+ const mainToken = forgeState.token;
35
+
36
+ // Check if already staked for this token
37
+ const stakeAddress = toStakeAddress(wallet.address, mainToken.address, tokenSymbol);
38
+ const { state: stakeState } = await client.getStakeState({ address: stakeAddress });
39
+
40
+ if (stakeState?.revocable) {
41
+ logger.info('Already staked for token creation', { stakeState, tokenSymbol });
42
+
43
+ const stakedToken = stakeState.tokens?.find((item: any) => item.address === mainToken.address);
44
+ const actualStake = new BN(stakedToken?.value || 0);
45
+
46
+ if (actualStake.gte(requiredStake)) {
47
+ return { client, forgeState, stakeAddress };
48
+ }
49
+ }
50
+
51
+ // Get account state to check balance
52
+ const { state: account } = await client.getAccountState({ address: wallet.address });
53
+
54
+ // Check if we have enough balance to stake
55
+ const holding = (account?.tokens || []).find((x: any) => x.address === mainToken.address);
56
+ const balance = toBN(holding?.value || '0');
57
+
58
+ if (balance.lt(requiredStake)) {
59
+ throw new Error(
60
+ `Insufficient balance to stake. Required: ${fromUnitToToken(requiredStake, mainToken.decimal)} ${mainToken.symbol}, Current: ${fromUnitToToken(balance, mainToken.decimal)} ${mainToken.symbol}`
61
+ );
62
+ }
63
+
64
+ logger.info('Staking for token creation', {
65
+ amount: fromUnitToToken(requiredStake, mainToken.decimal),
66
+ token: mainToken.symbol,
67
+ tokenSymbol,
68
+ });
69
+
70
+ // @ts-ignore
71
+ const [hash] = await client.stake({
72
+ to: mainToken.address,
73
+ nonce: tokenSymbol,
74
+ message: 'stake for create token factory',
75
+ tokens: [{ address: mainToken.address, value: fromUnitToToken(requiredStake, mainToken.decimal) }],
76
+ wallet,
77
+ });
78
+
79
+ logger.info('Stake transaction successful', { hash, stakeAddress, tokenSymbol });
80
+
81
+ return { client, forgeState, stakeAddress };
82
+ } catch (error) {
83
+ logger.error('Failed to stake for token creation', { tokenSymbol, error });
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ async function createTokenVC(data: { tokenAddress: string; symbol: string; website: string; chainId: string }) {
89
+ // Compatible with different chainId for main chain and Payment Kit
90
+ const chainId = data.chainId === 'main' ? 'xenon-2020-01-15' : data.chainId;
91
+ const subject = {
92
+ id: wallet.address,
93
+ issued: {
94
+ address: data.tokenAddress,
95
+ symbol: data.symbol,
96
+ website: data.website,
97
+ chainId,
98
+ },
99
+ display: {
100
+ type: 'url',
101
+ content: `${data.website.replace(/\/$/, '')}/.well-known/ocap/tokens.json`,
102
+ },
103
+ };
104
+
105
+ const typeInfo = toTypeInfo(wallet.address);
106
+ const vcType = { ...typeInfo, role: types.RoleType.ROLE_VC };
107
+ const vcDid = fromPublicKeyHash(wallet.hash(stringify(subject)!), vcType);
108
+
109
+ const vc = {
110
+ '@context': 'https://schema.arcblock.io/v0.1/context.jsonld',
111
+ id: vcDid,
112
+ type: 'TokenIssueCredential',
113
+ issuer: {
114
+ id: wallet.address,
115
+ pk: toBase58(wallet.publicKey),
116
+ name: wallet.address,
117
+ },
118
+ issuanceDate: new Date().toISOString(),
119
+ credentialSubject: subject,
120
+ };
121
+
122
+ const signature = await wallet.sign(stringify(vc)!);
123
+
124
+ const proofType = {
125
+ [types.KeyType.ED25519]: 'Ed25519Signature2018',
126
+ [types.KeyType.SECP256K1]: 'Secp256k1Signature2019',
127
+ [types.KeyType.ETHEREUM]: 'EthereumEip712Signature2021',
128
+ }[typeInfo.pk!];
129
+
130
+ const proof = {
131
+ type: proofType,
132
+ created: vc.issuanceDate,
133
+ proofPurpose: 'assertionMethod',
134
+ jws: toBase64(signature),
135
+ };
136
+
137
+ // @ts-ignore
138
+ vc.proof = proof;
139
+
140
+ const isValid = await verifyVC({
141
+ vc,
142
+ ownerDid: wallet.address,
143
+ trustedIssuers: wallet.address,
144
+ });
145
+
146
+ if (!isValid) {
147
+ throw new Error('Failed to verify token VC, please try again');
148
+ }
149
+
150
+ return vc;
151
+ }
152
+
153
+ /**
154
+ * Step 2: Publish VC (Verifiable Credential) for token via routing rule
155
+ */
156
+ async function publishTokenVC(vc: any) {
157
+ const { blocklet: blockletInfo } = await blockletService.getBlocklet();
158
+ const site = blockletInfo?.site;
159
+
160
+ if (!site?.id) {
161
+ throw new Error('Blocklet site configuration missing');
162
+ }
163
+
164
+ const prefix = '/.well-known/ocap/tokens.json';
165
+ const existingRule =
166
+ (site.rules || []).find(
167
+ (rule: any) => rule?.from?.pathPrefix === prefix || rule?.from?.pathPrefix === `${prefix}/`
168
+ ) || null;
169
+
170
+ let body: any[] = [];
171
+
172
+ // parse data from existing rule
173
+ if (existingRule) {
174
+ const rawBody = existingRule.to?.response?.body || '[]';
175
+ try {
176
+ body = JSON.parse(rawBody);
177
+ if (!Array.isArray(body)) {
178
+ body = [];
179
+ }
180
+ } catch (err) {
181
+ try {
182
+ body = JSON.parse(rawBody.replace(/\\n/g, '').replace(/\\"/g, '"'));
183
+ } catch (parseErr) {
184
+ logger.warn('Failed to parse existing tokens routing body, fallback to empty array', {
185
+ error: parseErr,
186
+ });
187
+ body = [];
188
+ }
189
+ }
190
+
191
+ // replace rule if it already exists
192
+ // create new rule if it doesn't exist
193
+ const index = body.findIndex(
194
+ (item: any) => item?.credentialSubject?.issued?.address === vc?.credentialSubject?.issued?.address
195
+ );
196
+ if (index > -1) {
197
+ body[index] = vc;
198
+ } else {
199
+ body.push(vc);
200
+ }
201
+ } else {
202
+ body = [vc];
203
+ }
204
+
205
+ const rule = {
206
+ from: { pathPrefix: prefix },
207
+ to: {
208
+ type: 'direct_response',
209
+ response: {
210
+ body: JSON.stringify(body, null, 2),
211
+ contentType: 'text/plain',
212
+ status: 200,
213
+ },
214
+ },
215
+ };
216
+
217
+ const isUpdate = !!existingRule;
218
+ const method = isUpdate ? 'updateRoutingRule' : 'addRoutingRule';
219
+
220
+ logger.info('Calling routing rule API', {
221
+ method,
222
+ siteId: site.id,
223
+ hasExistingRule: isUpdate,
224
+ });
225
+
226
+ try {
227
+ let result;
228
+ if (isUpdate) {
229
+ result = await blockletService.updateRoutingRule({
230
+ id: site.id,
231
+ rule: {
232
+ id: existingRule.id,
233
+ ...rule,
234
+ },
235
+ });
236
+ } else {
237
+ result = await blockletService.addRoutingRule({
238
+ id: site.id,
239
+ rule,
240
+ });
241
+ }
242
+
243
+ logger.info('Token VC published successfully', {
244
+ method,
245
+ prefix,
246
+ rulesCount: result?.site?.rules?.length,
247
+ });
248
+ } catch (error) {
249
+ logger.error(`Failed to call ${method} via SDK`, { error });
250
+ throw error;
251
+ }
252
+ }
253
+
254
+ export async function createToken(data: { name: string; symbol: string; decimal?: number; livemode?: boolean }) {
255
+ const livemode = data.livemode ?? true;
256
+
257
+ try {
258
+ // Find the arcblock payment method for the current environment
259
+ const paymentMethod = await PaymentMethod.findOne({
260
+ where: {
261
+ livemode: !!livemode,
262
+ type: 'arcblock',
263
+ },
264
+ });
265
+ if (!paymentMethod) {
266
+ throw new Error('ArcBlock payment method not found');
267
+ }
268
+
269
+ const chainId = paymentMethod.settings.arcblock?.chain_id;
270
+ if (!chainId) {
271
+ throw new Error('Chain configuration incomplete');
272
+ }
273
+
274
+ const client = paymentMethod.getOcapClient();
275
+ const { state: forgeState } = await client.getForgeState({});
276
+
277
+ const factoryItx: any = {
278
+ token: {
279
+ name: data.name,
280
+ symbol: data.symbol,
281
+ description: `Token created by ${env.appName || 'Payment Kit'}`,
282
+ website: env.appUrl || process.env.BLOCKLET_APP_HOST,
283
+ icon: '',
284
+ maxTotalSupply: null,
285
+ decimal: data.decimal ?? 10,
286
+ unit: 'A',
287
+ type: 'CreditToken',
288
+ spenders: [wallet.address],
289
+ minters: [wallet.address],
290
+ metadata: {
291
+ type: 'json',
292
+ value: {
293
+ issuer: env.appName || 'Payment Kit',
294
+ },
295
+ },
296
+ },
297
+ feeRate: 0,
298
+ reserveAddress: forgeState.token.address,
299
+ };
300
+
301
+ factoryItx.token.address = toTokenAddress(factoryItx.token);
302
+ factoryItx.address = toTokenFactoryAddress(factoryItx);
303
+
304
+ // Step 1: Create and publish VC
305
+ logger.info('Creating and publishing token VC', { symbol: data.symbol });
306
+
307
+ const vc = await createTokenVC({
308
+ tokenAddress: factoryItx.token.address,
309
+ symbol: data.symbol,
310
+ website: env.appUrl || process.env.BLOCKLET_APP_HOST!,
311
+ chainId,
312
+ });
313
+
314
+ await publishTokenVC(vc);
315
+ // wait for 1 second to ensure the VC is published
316
+ await sleep(1000);
317
+
318
+ // Step 2: Stake ABT for token creation
319
+ logger.info('Staking for token creation', { symbol: data.symbol });
320
+
321
+ await stakeForTokenCreation(data.symbol, client);
322
+
323
+ // Step 3: Create token factory on chain
324
+ logger.info('Step 3: Creating token factory transaction', { symbol: data.symbol });
325
+
326
+ const hash = await client.sendCreateTokenFactoryTx({
327
+ tx: {
328
+ itx: factoryItx,
329
+ },
330
+ wallet,
331
+ });
332
+ const { state: tokenFactoryState } = await client.getTokenFactoryState({ address: factoryItx.address });
333
+
334
+ logger.info('Token created successfully', { hash, ...tokenFactoryState });
335
+
336
+ return tokenFactoryState;
337
+ } catch (err) {
338
+ logger.error('Failed to create token', { data, error: err });
339
+ throw err;
340
+ }
341
+ }
342
+
343
+ export async function getTokenContext(paymentCurrency: TPaymentCurrency) {
344
+ const tokenConfig = paymentCurrency.token_config;
345
+
346
+ if (!tokenConfig?.address) {
347
+ throw new Error(`Payment currency ${paymentCurrency.id} is missing token_config`);
348
+ }
349
+
350
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
351
+
352
+ if (paymentMethod?.type !== 'arcblock') {
353
+ throw new Error(`Payment method ${paymentMethod?.id} must be ArcBlock`);
354
+ }
355
+
356
+ return {
357
+ tokenConfig,
358
+ client: paymentMethod.getOcapClient() as any,
359
+ };
360
+ }
361
+
362
+ export async function mintToken({
363
+ paymentCurrency,
364
+ amount,
365
+ receiver,
366
+ creditGrantId,
367
+ }: {
368
+ paymentCurrency: TPaymentCurrency;
369
+ amount: string;
370
+ receiver: string;
371
+ creditGrantId?: string;
372
+ }): Promise<string> {
373
+ const { tokenConfig, client } = await getTokenContext(paymentCurrency);
374
+
375
+ logger.info('Minting credit token', {
376
+ creditGrantId,
377
+ paymentCurrencyId: paymentCurrency.id,
378
+ tokenFactory: tokenConfig.token_factory_address,
379
+ amount,
380
+ receiver,
381
+ tokenConfig,
382
+ });
383
+
384
+ const hash = await (client as any).mintToken({
385
+ wallet,
386
+ tokenFactory: tokenConfig.token_factory_address,
387
+ amount,
388
+ receiver,
389
+ data: {
390
+ typeUrl: 'json',
391
+ value: {
392
+ creditGrantId,
393
+ },
394
+ },
395
+ });
396
+
397
+ logger.info('Successfully minted credit token', {
398
+ creditGrantId,
399
+ tokenFactory: tokenConfig.token_factory_address,
400
+ hash,
401
+ });
402
+
403
+ return hash;
404
+ }
405
+
406
+ export async function burnToken({
407
+ paymentCurrency,
408
+ amount,
409
+ sender,
410
+ data,
411
+ }: {
412
+ paymentCurrency: TPaymentCurrency;
413
+ amount: string | number;
414
+ sender: string;
415
+ data?: any;
416
+ }): Promise<string> {
417
+ const { tokenConfig, client } = await getTokenContext(paymentCurrency);
418
+
419
+ logger.info('Burning on-chain credit token via transfer then burn', {
420
+ paymentCurrencyId: paymentCurrency.id,
421
+ tokenFactory: tokenConfig.token_factory_address,
422
+ amount,
423
+ sender,
424
+ });
425
+
426
+ // Step 1: Transfer tokens from sender to owner using multi-signature
427
+ logger.info('Step 1: Transferring tokens from sender to owner', {
428
+ from: sender,
429
+ to: wallet.address,
430
+ amount,
431
+ });
432
+
433
+ const transferHash = await transferTokenFromCustomer({
434
+ paymentCurrency,
435
+ customerDid: sender,
436
+ amount: fromTokenToUnit(amount.toString(), paymentCurrency.decimal).toString(),
437
+ data: {
438
+ reason: 'burn_expired_credit',
439
+ ...(data || {}),
440
+ },
441
+ checkBalance: false, // Don't check balance as we're burning expired tokens
442
+ });
443
+
444
+ if (!transferHash) {
445
+ throw new Error('Failed to transfer tokens from customer');
446
+ }
447
+
448
+ logger.info('Step 1 completed: Tokens transferred to owner', {
449
+ transferHash,
450
+ from: sender,
451
+ to: wallet.address,
452
+ amount,
453
+ });
454
+
455
+ // Step 2: Burn tokens from owner wallet
456
+ logger.info('Step 2: Burning tokens from owner wallet', {
457
+ amount,
458
+ tokenFactory: tokenConfig.token_factory_address,
459
+ transferHash,
460
+ });
461
+
462
+ const burnHash = await client.burnToken({
463
+ tokenFactory: tokenConfig.token_factory_address,
464
+ wallet,
465
+ amount,
466
+ receiver: wallet.address,
467
+ data: {
468
+ typeUrl: 'json',
469
+ value: {
470
+ reason: 'burn_expired_credit',
471
+ transferHash,
472
+ ...(data || {}),
473
+ },
474
+ },
475
+ });
476
+
477
+ logger.info('Step 2 completed: Tokens burned successfully', {
478
+ paymentCurrencyId: paymentCurrency.id,
479
+ burnHash,
480
+ transferHash,
481
+ tokenFactory: tokenConfig.token_factory_address,
482
+ originalSender: sender,
483
+ });
484
+
485
+ return burnHash;
486
+ }
487
+
488
+ /**
489
+ * Get customer's on-chain token balance
490
+ */
491
+ export async function getCustomerTokenBalance(customerDid: string, paymentCurrency: TPaymentCurrency): Promise<string> {
492
+ const { client, tokenConfig } = await getTokenContext(paymentCurrency);
493
+
494
+ const { tokens } = await client.getAccountTokens({
495
+ address: customerDid,
496
+ token: tokenConfig.address,
497
+ });
498
+ const [token] = tokens;
499
+
500
+ return token?.balance || '0';
501
+ }
502
+
503
+ /**
504
+ * Transfer token from user wallet to system wallet
505
+ * Uses multi-signature mechanism where system initiates and user authorizes
506
+ * This is used for credit consumption, expired token burn, etc.
507
+ */
508
+ export async function transferTokenFromCustomer({
509
+ paymentCurrency,
510
+ customerDid,
511
+ amount,
512
+ data,
513
+ checkBalance = true,
514
+ }: {
515
+ paymentCurrency: TPaymentCurrency;
516
+ customerDid: string;
517
+ amount: string;
518
+ data: any; // Transaction data (reason, metadata, etc)
519
+ checkBalance?: boolean; // Whether to check balance before transfer
520
+ }) {
521
+ // Only process for on-chain credit
522
+ if (!isOnchainCredit(paymentCurrency)) {
523
+ return null;
524
+ }
525
+
526
+ const { client, tokenConfig } = await getTokenContext(paymentCurrency);
527
+
528
+ // Optional balance check
529
+ if (checkBalance) {
530
+ const balance = await getCustomerTokenBalance(customerDid, paymentCurrency);
531
+
532
+ if (!balance || new BN(balance).lt(new BN(amount))) {
533
+ logger.error('User does not have enough token balance', {
534
+ from: customerDid,
535
+ tokenAddress: tokenConfig.address,
536
+ required: amount,
537
+ actual: balance || '0',
538
+ data,
539
+ });
540
+ throw new Error('INSUFFICIENT_TOKEN_BALANCE');
541
+ }
542
+ }
543
+
544
+ logger.info('Transferring tokens from customer wallet', {
545
+ amount,
546
+ from: customerDid,
547
+ to: wallet.address,
548
+ tokenAddress: tokenConfig.address,
549
+ data,
550
+ });
551
+
552
+ // transfer tokens from user to system owner via multi-signature
553
+ const signed = await client.signTransferV3Tx({
554
+ tx: {
555
+ itx: {
556
+ inputs: [
557
+ {
558
+ owner: customerDid,
559
+ tokens: [{ address: tokenConfig.address, value: amount }],
560
+ },
561
+ ],
562
+ outputs: [
563
+ {
564
+ owner: wallet.address,
565
+ tokens: [{ address: tokenConfig.address, value: amount }],
566
+ },
567
+ ],
568
+ data: {
569
+ typeUrl: 'json',
570
+ // @ts-ignore
571
+ value: data,
572
+ },
573
+ },
574
+ signatures: [
575
+ {
576
+ signer: customerDid,
577
+ pk: '',
578
+ signature: '',
579
+ },
580
+ ],
581
+ },
582
+ wallet,
583
+ });
584
+
585
+ // @ts-ignore
586
+ const { buffer } = await client.encodeTransferV3Tx({ tx: signed });
587
+ // @ts-ignore
588
+ const txHash = await client.sendTransferV3Tx({ tx: signed, wallet }, await getGasPayerExtra(buffer));
589
+
590
+ logger.info('Token transferred from customer wallet', {
591
+ txHash,
592
+ amount,
593
+ from: customerDid,
594
+ tokenAddress: tokenConfig.address,
595
+ data,
596
+ });
597
+
598
+ return txHash;
599
+ }
@@ -49,8 +49,8 @@ export async function createCreditGrant(params: {
49
49
  if (params.expires_at && params.expires_at < now) {
50
50
  throw new Error('expires_at must be in the future');
51
51
  }
52
- const isEffectiveNow = !params.effective_at || params.effective_at <= now;
53
- const initialStatus = isEffectiveNow ? 'granted' : 'pending';
52
+ // Always start with 'pending' status, let activateGrant handle the activation
53
+ const initialStatus = 'pending';
54
54
  const applicabilityConfig = params.applicability_config || {
55
55
  scope: {
56
56
  type: 'metered',
@@ -122,15 +122,16 @@ export function calculateExpiresAt(validDurationValue: number, validDurationUnit
122
122
  const now = dayjs();
123
123
  let expiresAt: dayjs.Dayjs;
124
124
 
125
+ // dayjs rounds decimal values for day/week units, so convert to mintues first
125
126
  switch (validDurationUnit) {
126
127
  case 'hours':
127
- expiresAt = now.add(validDurationValue, 'hour');
128
+ expiresAt = now.add(validDurationValue * 60, 'minute');
128
129
  break;
129
130
  case 'days':
130
- expiresAt = now.add(validDurationValue, 'day');
131
+ expiresAt = now.add(validDurationValue * 24 * 60, 'minute');
131
132
  break;
132
133
  case 'weeks':
133
- expiresAt = now.add(validDurationValue, 'week');
134
+ expiresAt = now.add(validDurationValue * 7 * 24 * 60, 'minute');
134
135
  break;
135
136
  case 'months':
136
137
  expiresAt = now.add(validDurationValue, 'month');
@@ -139,7 +140,7 @@ export function calculateExpiresAt(validDurationValue: number, validDurationUnit
139
140
  expiresAt = now.add(validDurationValue, 'year');
140
141
  break;
141
142
  default:
142
- expiresAt = now.add(validDurationValue, 'day');
143
+ expiresAt = now.add(validDurationValue * 24 * 60, 'minute');
143
144
  }
144
145
 
145
146
  return expiresAt.unix();
@@ -97,6 +97,40 @@ export function createCodeGenerator(prefix: string, size: number = 24) {
97
97
  return prefix ? () => `${prefix}_${generator()}` : generator;
98
98
  }
99
99
 
100
+ export function hasObjectChanged<T extends Record<string, any>>(
101
+ updates: Partial<T>,
102
+ original: T,
103
+ options?: {
104
+ deepCompare?: string[];
105
+ }
106
+ ): boolean {
107
+ const deepFields = options?.deepCompare || [];
108
+
109
+ for (const [key, newValue] of Object.entries(updates)) {
110
+ if (newValue !== undefined) {
111
+ const oldValue = original[key];
112
+
113
+ if (deepFields.includes(key)) {
114
+ if (typeof newValue === 'object' && newValue !== null && typeof oldValue === 'object' && oldValue !== null) {
115
+ const newObj = newValue as Record<string, any>;
116
+ const oldObj = (oldValue || {}) as Record<string, any>;
117
+ for (const subKey of Object.keys(newObj)) {
118
+ if (newObj[subKey] !== oldObj[subKey]) {
119
+ return true;
120
+ }
121
+ }
122
+ } else if (newValue !== oldValue) {
123
+ return true;
124
+ }
125
+ } else if (newValue !== oldValue) {
126
+ return true;
127
+ }
128
+ }
129
+ }
130
+
131
+ return false;
132
+ }
133
+
100
134
  export function stringToStream(str: string): Readable {
101
135
  const stream = new Readable();
102
136
  stream.push(str);