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.
- package/api/src/index.ts +4 -0
- package/api/src/integrations/arcblock/token.ts +599 -0
- package/api/src/libs/credit-grant.ts +7 -6
- package/api/src/libs/util.ts +34 -0
- package/api/src/queues/credit-consume.ts +29 -4
- package/api/src/queues/credit-grant.ts +245 -50
- package/api/src/queues/credit-reconciliation.ts +253 -0
- package/api/src/queues/refund.ts +263 -30
- package/api/src/queues/token-transfer.ts +331 -0
- package/api/src/routes/checkout-sessions.ts +94 -29
- package/api/src/routes/credit-grants.ts +35 -9
- package/api/src/routes/credit-tokens.ts +38 -0
- package/api/src/routes/credit-transactions.ts +20 -3
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/meter-events.ts +4 -0
- package/api/src/routes/meters.ts +32 -10
- package/api/src/routes/payment-currencies.ts +103 -0
- package/api/src/routes/payment-links.ts +3 -1
- package/api/src/routes/products.ts +2 -2
- package/api/src/routes/settings.ts +4 -3
- package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
- package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
- package/api/src/store/migrations/20251211-optimize-slow-queries.ts +33 -0
- package/api/src/store/models/credit-grant.ts +47 -9
- package/api/src/store/models/credit-transaction.ts +18 -1
- package/api/src/store/models/index.ts +2 -1
- package/api/src/store/models/payment-currency.ts +31 -4
- package/api/src/store/models/refund.ts +12 -2
- package/api/src/store/models/types.ts +48 -0
- package/api/src/store/sequelize.ts +1 -0
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/app.tsx +10 -0
- package/src/components/customer/credit-overview.tsx +19 -3
- package/src/components/meter/form.tsx +191 -18
- package/src/components/price/form.tsx +49 -37
- package/src/locales/en.tsx +25 -1
- package/src/locales/zh.tsx +27 -1
- package/src/pages/admin/billing/meters/create.tsx +42 -13
- package/src/pages/admin/billing/meters/detail.tsx +56 -5
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +13 -0
- package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +324 -0
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/customer/credit-grant/detail.tsx +14 -1
- package/src/pages/customer/credit-transaction/detail.tsx +289 -0
- package/src/pages/customer/invoice/detail.tsx +1 -1
- package/src/pages/customer/recharge/subscription.tsx +1 -1
- 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
|
-
|
|
53
|
-
const initialStatus =
|
|
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, '
|
|
128
|
+
expiresAt = now.add(validDurationValue * 60, 'minute');
|
|
128
129
|
break;
|
|
129
130
|
case 'days':
|
|
130
|
-
expiresAt = now.add(validDurationValue, '
|
|
131
|
+
expiresAt = now.add(validDurationValue * 24 * 60, 'minute');
|
|
131
132
|
break;
|
|
132
133
|
case 'weeks':
|
|
133
|
-
expiresAt = now.add(validDurationValue, '
|
|
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, '
|
|
143
|
+
expiresAt = now.add(validDurationValue * 24 * 60, 'minute');
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
return expiresAt.unix();
|
package/api/src/libs/util.ts
CHANGED
|
@@ -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);
|