payment-kit 1.25.0 → 1.25.2
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/integrations/arcblock/stake.ts +38 -20
- package/api/src/libs/payment.ts +34 -6
- package/api/src/libs/session.ts +18 -2
- package/api/src/libs/subscription.ts +27 -5
- package/api/src/libs/wallet-migration.ts +317 -0
- package/api/src/queues/auto-recharge.ts +1 -0
- package/api/src/queues/payment.ts +17 -18
- package/api/src/queues/refund.ts +43 -10
- package/api/src/queues/subscription.ts +3 -2
- package/api/src/routes/auto-recharge-configs.ts +1 -0
- package/api/src/routes/checkout-sessions.ts +2 -2
- package/api/src/routes/connect/change-payer.ts +1 -0
- package/api/src/routes/connect/shared.ts +1 -0
- package/api/src/routes/subscriptions.ts +4 -0
- package/api/src/store/models/types.ts +1 -0
- package/api/tests/libs/wallet-migration.spec.ts +271 -0
- package/blocklet.yml +1 -1
- package/package.json +6 -6
|
@@ -13,6 +13,7 @@ import logger from '../../libs/logger';
|
|
|
13
13
|
import { Customer, GroupedBN, PaymentCurrency, PaymentMethod, Subscription } from '../../store/models';
|
|
14
14
|
import { fetchErc20Balance, fetchEtherBalance } from '../ethereum/token';
|
|
15
15
|
import { EVM_CHAIN_TYPES } from '../../libs/constants';
|
|
16
|
+
import { getAppStakeAddressWithFallback, getMigratedFromList } from '../../libs/wallet-migration';
|
|
16
17
|
|
|
17
18
|
export async function ensureStakedForGas() {
|
|
18
19
|
const currencies = await PaymentCurrency.findAll({ where: { active: true, is_base_currency: true } });
|
|
@@ -26,10 +27,10 @@ export async function ensureStakedForGas() {
|
|
|
26
27
|
try {
|
|
27
28
|
const { state: account } = await client.getAccountState({ address: wallet.address });
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
logger.info(`app already staked for gas on chain ${host}
|
|
30
|
+
// Use migration-aware fallback to check if app has staked for gas
|
|
31
|
+
const stakeResult = await getAppStakeAddressWithFallback({ client });
|
|
32
|
+
if (stakeResult) {
|
|
33
|
+
logger.info(`app already staked for gas on chain ${host}`, { source: stakeResult.source });
|
|
33
34
|
continue;
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -59,6 +60,14 @@ export async function ensureStakedForGas() {
|
|
|
59
60
|
export async function hasStakedForGas(method: PaymentMethod, sender = wallet.address) {
|
|
60
61
|
if (method.type === 'arcblock') {
|
|
61
62
|
const client = method.getOcapClient();
|
|
63
|
+
|
|
64
|
+
// If checking app's own stake, use migration-aware fallback
|
|
65
|
+
if (sender === wallet.address) {
|
|
66
|
+
const stakeResult = await getAppStakeAddressWithFallback({ client });
|
|
67
|
+
return !!stakeResult;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// For other senders, use direct query
|
|
62
71
|
const address = toStakeAddress(sender, sender);
|
|
63
72
|
const { state } = await client.getStakeState({ address });
|
|
64
73
|
return !!state;
|
|
@@ -195,23 +204,32 @@ export async function getStakeSummaryByDid(did: string, livemode: boolean = true
|
|
|
195
204
|
await Promise.all(
|
|
196
205
|
methods.map(async (method: PaymentMethod) => {
|
|
197
206
|
const client = method.getOcapClient();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (currency) {
|
|
211
|
-
results[currency.id] = new BN(results[currency.id] || '0').add(new BN(t.balance || '0')).toString();
|
|
212
|
-
}
|
|
213
|
-
});
|
|
207
|
+
|
|
208
|
+
// Get all possible receiver addresses (current + migratedFrom) for migration compatibility
|
|
209
|
+
const migratedFrom = await getMigratedFromList(client);
|
|
210
|
+
const receiverAddresses = [...new Set([wallet.address, ...migratedFrom])];
|
|
211
|
+
|
|
212
|
+
// Query stakes for all possible receiver addresses
|
|
213
|
+
for (const receiver of receiverAddresses) {
|
|
214
|
+
// eslint-disable-next-line no-await-in-loop
|
|
215
|
+
const { stakes } = await client.listStakes({
|
|
216
|
+
addressFilter: {
|
|
217
|
+
receiver,
|
|
218
|
+
},
|
|
214
219
|
});
|
|
220
|
+
(stakes || [])
|
|
221
|
+
.filter((x: any) => x.sender === did)
|
|
222
|
+
.forEach((x: any) => {
|
|
223
|
+
const { tokens } = x;
|
|
224
|
+
(tokens || []).forEach((t: any) => {
|
|
225
|
+
// @ts-ignore
|
|
226
|
+
const currency = method.payment_currencies?.find((c: PaymentCurrency) => t.address === c.contract);
|
|
227
|
+
if (currency) {
|
|
228
|
+
results[currency.id] = new BN(results[currency.id] || '0').add(new BN(t.balance || '0')).toString();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
215
233
|
})
|
|
216
234
|
);
|
|
217
235
|
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/indent */
|
|
2
2
|
import { isEthereumDid } from '@arcblock/did';
|
|
3
|
-
import { toDelegateAddress } from '@arcblock/did-util';
|
|
4
3
|
import { getWalletDid } from '@blocklet/sdk/lib/did';
|
|
5
4
|
import type { DelegateState, TokenLimit } from '@ocap/client';
|
|
6
5
|
import { toTxHash } from '@ocap/mcrypto';
|
|
@@ -20,7 +19,9 @@ import {
|
|
|
20
19
|
Payout,
|
|
21
20
|
CreditGrant,
|
|
22
21
|
Customer,
|
|
22
|
+
Subscription,
|
|
23
23
|
} from '../store/models';
|
|
24
|
+
import { getDelegationAddressWithFallback, backfillDelegationAddress, getMigratedFromList } from './wallet-migration';
|
|
24
25
|
import type { TPaymentCurrency } from '../store/models/payment-currency';
|
|
25
26
|
import { blocklet, ethWallet, wallet, getVaultAddress } from './auth';
|
|
26
27
|
import logger from './logger';
|
|
@@ -212,8 +213,11 @@ export async function isDelegationSufficientForPayment(args: {
|
|
|
212
213
|
amount: string;
|
|
213
214
|
delegatorAmounts?: string[];
|
|
214
215
|
lineItems?: TLineItemExpanded[];
|
|
216
|
+
subscription?: Subscription | null; // Optional subscription for migration fallback
|
|
217
|
+
storedDelegationAddress?: string; // Direct stored address (for auto-recharge etc.)
|
|
215
218
|
}): Promise<SufficientForPaymentResult> {
|
|
216
|
-
const { paymentCurrency, paymentMethod, userDid, amount, delegatorAmounts } =
|
|
219
|
+
const { paymentCurrency, paymentMethod, userDid, amount, delegatorAmounts, subscription, storedDelegationAddress } =
|
|
220
|
+
args;
|
|
217
221
|
if (!userDid) {
|
|
218
222
|
return { sufficient: false, reason: 'NO_DID_WALLET' };
|
|
219
223
|
}
|
|
@@ -251,14 +255,32 @@ export async function isDelegationSufficientForPayment(args: {
|
|
|
251
255
|
|
|
252
256
|
const client = paymentMethod.getOcapClient();
|
|
253
257
|
|
|
254
|
-
// have delegated before?
|
|
255
|
-
|
|
258
|
+
// have delegated before? Use migration-aware fallback query
|
|
259
|
+
// Priority: storedDelegationAddress > subscription.payment_details.arcblock.delegation_address
|
|
260
|
+
const delegationResult = await getDelegationAddressWithFallback({
|
|
261
|
+
storedAddress: storedDelegationAddress || subscription?.payment_details?.arcblock?.delegation_address,
|
|
262
|
+
delegator,
|
|
263
|
+
client,
|
|
264
|
+
});
|
|
265
|
+
if (!delegationResult) {
|
|
266
|
+
return { sufficient: false, reason: 'NO_DELEGATION' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { address, needsBackfill, source } = delegationResult;
|
|
270
|
+
|
|
271
|
+
// Backfill if needed and subscription is available
|
|
272
|
+
if (needsBackfill && subscription) {
|
|
273
|
+
await backfillDelegationAddress(subscription.id, address);
|
|
274
|
+
}
|
|
275
|
+
|
|
256
276
|
const { state } = await client.getDelegateState({ address });
|
|
257
277
|
if (!state) {
|
|
278
|
+
logger.error('isDelegationSufficientForPayment: no delegation state', { address, delegator, source });
|
|
258
279
|
return { sufficient: false, reason: 'NO_DELEGATION' };
|
|
259
280
|
}
|
|
260
281
|
|
|
261
282
|
if (!state.ops || state.ops?.length === 0) {
|
|
283
|
+
logger.error('isDelegationSufficientForPayment: no delegation ops', { address, delegator, source });
|
|
262
284
|
return { sufficient: false, reason: 'NO_DELEGATION' };
|
|
263
285
|
}
|
|
264
286
|
|
|
@@ -277,8 +299,14 @@ export async function isDelegationSufficientForPayment(args: {
|
|
|
277
299
|
|
|
278
300
|
// FIXME: @wangshijun check other conditions in the token limit: txCount, totalAllowance, validUntil, rateLimit
|
|
279
301
|
|
|
280
|
-
if
|
|
281
|
-
|
|
302
|
+
// Check if tokenLimit.to includes current wallet.address or any migratedFrom addresses
|
|
303
|
+
if (Array.isArray(tokenLimit.to) && tokenLimit.to.length) {
|
|
304
|
+
const migratedFrom = await getMigratedFromList(client);
|
|
305
|
+
const allWalletAddresses = [wallet.address, ...migratedFrom];
|
|
306
|
+
const hasValidTo = allWalletAddresses.some((addr) => tokenLimit.to.includes(addr));
|
|
307
|
+
if (!hasValidTo) {
|
|
308
|
+
return { sufficient: false, reason: 'NO_TRANSFER_TO' };
|
|
309
|
+
}
|
|
282
310
|
}
|
|
283
311
|
|
|
284
312
|
// check allowance
|
package/api/src/libs/session.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
} from '../store/models/types';
|
|
36
36
|
import { wallet } from './auth';
|
|
37
37
|
import logger from './logger';
|
|
38
|
+
import { getMigratedFromList } from './wallet-migration';
|
|
38
39
|
import { applyDiscountsToLineItems } from './discount/discount';
|
|
39
40
|
import { getQuoteService, QuoteResponse } from './quote-service';
|
|
40
41
|
import { getExchangeRateService } from './exchange-rate';
|
|
@@ -443,8 +444,23 @@ export function getMinStakeAmount(config: Record<string, any> = {}) {
|
|
|
443
444
|
return 0;
|
|
444
445
|
}
|
|
445
446
|
|
|
446
|
-
export function canPayWithDelegation(
|
|
447
|
-
|
|
447
|
+
export async function canPayWithDelegation(
|
|
448
|
+
beneficiaries: PaymentBeneficiary[],
|
|
449
|
+
paymentMethod?: PaymentMethod
|
|
450
|
+
): Promise<boolean> {
|
|
451
|
+
if (beneficiaries.length === 0) {
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Get all valid wallet addresses (current + migrated)
|
|
456
|
+
let allWalletAddresses = [wallet.address];
|
|
457
|
+
if (paymentMethod?.type === 'arcblock') {
|
|
458
|
+
const client = paymentMethod.getOcapClient();
|
|
459
|
+
const migratedFrom = await getMigratedFromList(client);
|
|
460
|
+
allWalletAddresses = [wallet.address, ...migratedFrom];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return beneficiaries.every((x) => allWalletAddresses.includes(x.address));
|
|
448
464
|
}
|
|
449
465
|
|
|
450
466
|
export function createPaymentBeneficiaries(total: string, beneficiaries: PaymentBeneficiary[]) {
|
|
@@ -37,6 +37,7 @@ import { trimDecimals, limitTokenPrecision } from './math-utils';
|
|
|
37
37
|
import { getPriceCurrencyOptions, getPriceUintAmountByCurrency } from './price';
|
|
38
38
|
import { getRecurringPeriod, getSubscriptionCreateSetup, SlippageOptions } from './session';
|
|
39
39
|
import { getConnectQueryParam, getCustomerStakeAddress } from './util';
|
|
40
|
+
import { getStakingAddressWithFallback } from './wallet-migration';
|
|
40
41
|
import { wallet } from './auth';
|
|
41
42
|
import { getGasPayerExtra } from './payment';
|
|
42
43
|
import { getLock } from './lock';
|
|
@@ -1056,11 +1057,32 @@ export function getSubscriptionTrialSetup(data: Partial<SubscriptionData>, curre
|
|
|
1056
1057
|
};
|
|
1057
1058
|
}
|
|
1058
1059
|
|
|
1059
|
-
export async function getSubscriptionStakeAddress(
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1060
|
+
export async function getSubscriptionStakeAddress(
|
|
1061
|
+
subscription: Subscription,
|
|
1062
|
+
customerDid: string,
|
|
1063
|
+
paymentMethod?: PaymentMethod
|
|
1064
|
+
) {
|
|
1065
|
+
// If stored address exists, use it
|
|
1066
|
+
const storedAddress = subscription.payment_details?.arcblock?.staking?.address;
|
|
1067
|
+
if (storedAddress) {
|
|
1068
|
+
return storedAddress;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Use migration-aware fallback if paymentMethod is available
|
|
1072
|
+
if (paymentMethod?.type === 'arcblock') {
|
|
1073
|
+
const client = paymentMethod.getOcapClient();
|
|
1074
|
+
const stakingResult = await getStakingAddressWithFallback({
|
|
1075
|
+
userDid: customerDid,
|
|
1076
|
+
nonce: subscription.id,
|
|
1077
|
+
client,
|
|
1078
|
+
});
|
|
1079
|
+
if (stakingResult) {
|
|
1080
|
+
return stakingResult.address;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Fallback to direct calculation
|
|
1085
|
+
return getCustomerStakeAddress(customerDid, subscription.id);
|
|
1064
1086
|
}
|
|
1065
1087
|
|
|
1066
1088
|
export async function getSubscriptionStakeCancellation(
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet Migration Compatibility
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities to handle wallet address changes after app migration.
|
|
5
|
+
* When an app's DID changes, the derived addresses (delegation, staking) also change,
|
|
6
|
+
* but user authorizations were made with the old addresses.
|
|
7
|
+
*
|
|
8
|
+
* This module provides fallback query mechanisms using migratedFrom list.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { toDelegateAddress, toStakeAddress } from '@arcblock/did-util';
|
|
12
|
+
import type OcapClient from '@ocap/client';
|
|
13
|
+
|
|
14
|
+
import { wallet } from './auth';
|
|
15
|
+
import logger from './logger';
|
|
16
|
+
import { Subscription } from '../store/models';
|
|
17
|
+
|
|
18
|
+
// Cache for migratedFrom list (refreshed when wallet.address changes)
|
|
19
|
+
let cachedMigratedFrom: string[] | null = null;
|
|
20
|
+
let cachedWalletAddress: string | null = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the migratedFrom list for the current app wallet (with caching)
|
|
24
|
+
* The cache is invalidated when wallet.address changes
|
|
25
|
+
*/
|
|
26
|
+
export async function getMigratedFromList(client: OcapClient): Promise<string[]> {
|
|
27
|
+
// If wallet address hasn't changed, use cached value
|
|
28
|
+
if (wallet.address === cachedWalletAddress && cachedMigratedFrom !== null) {
|
|
29
|
+
return cachedMigratedFrom;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Query chain for migratedFrom list
|
|
33
|
+
try {
|
|
34
|
+
const { state } = await client.getAccountState({ address: wallet.address });
|
|
35
|
+
cachedMigratedFrom = state?.migratedFrom || [];
|
|
36
|
+
cachedWalletAddress = wallet.address;
|
|
37
|
+
|
|
38
|
+
if (cachedMigratedFrom.length > 0) {
|
|
39
|
+
logger.info('wallet-migration: loaded migratedFrom list', {
|
|
40
|
+
walletAddress: wallet.address,
|
|
41
|
+
migratedFrom: cachedMigratedFrom,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return cachedMigratedFrom;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
logger.error('wallet-migration: failed to get migratedFrom list', { error: err });
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Clear the migratedFrom cache (for testing purposes)
|
|
54
|
+
*/
|
|
55
|
+
export function clearMigratedFromCache(): void {
|
|
56
|
+
cachedMigratedFrom = null;
|
|
57
|
+
cachedWalletAddress = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type AddressWithFallbackResult = {
|
|
61
|
+
address: string;
|
|
62
|
+
needsBackfill: boolean;
|
|
63
|
+
source: 'stored' | 'current' | 'migrated';
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type GetDelegationAddressParams = {
|
|
67
|
+
/** Stored delegation address from payment_details (if available) */
|
|
68
|
+
storedAddress?: string;
|
|
69
|
+
/** The delegator's DID (user's wallet DID) */
|
|
70
|
+
delegator: string;
|
|
71
|
+
/** OCAP client for chain queries */
|
|
72
|
+
client: OcapClient;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get the effective delegation address with migration fallback
|
|
77
|
+
*
|
|
78
|
+
* Priority:
|
|
79
|
+
* 1. Use stored delegation_address if provided
|
|
80
|
+
* 2. Try current wallet.address
|
|
81
|
+
* 3. Fallback to migratedFrom addresses
|
|
82
|
+
*/
|
|
83
|
+
export async function getDelegationAddressWithFallback({
|
|
84
|
+
storedAddress,
|
|
85
|
+
delegator,
|
|
86
|
+
client,
|
|
87
|
+
}: GetDelegationAddressParams): Promise<AddressWithFallbackResult | null> {
|
|
88
|
+
// 1. Check stored delegation_address first
|
|
89
|
+
if (storedAddress) {
|
|
90
|
+
return { address: storedAddress, needsBackfill: false, source: 'stored' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2. Try current wallet.address
|
|
94
|
+
const currentAddress = toDelegateAddress(delegator, wallet.address);
|
|
95
|
+
try {
|
|
96
|
+
const { state: currentState } = await client.getDelegateState({ address: currentAddress });
|
|
97
|
+
if (currentState?.ops?.length > 0) {
|
|
98
|
+
return { address: currentAddress, needsBackfill: true, source: 'current' };
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
logger.warn('wallet-migration: failed to query current delegation state', {
|
|
102
|
+
address: currentAddress,
|
|
103
|
+
error: err,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. Fallback to migratedFrom addresses
|
|
108
|
+
const migratedFrom = await getMigratedFromList(client);
|
|
109
|
+
for (const oldAppDid of migratedFrom) {
|
|
110
|
+
const oldAddress = toDelegateAddress(delegator, oldAppDid);
|
|
111
|
+
try {
|
|
112
|
+
// eslint-disable-next-line no-await-in-loop
|
|
113
|
+
const { state: oldState } = await client.getDelegateState({ address: oldAddress });
|
|
114
|
+
if (oldState?.ops?.length > 0) {
|
|
115
|
+
logger.info('wallet-migration: found delegation in migratedFrom', {
|
|
116
|
+
delegator,
|
|
117
|
+
oldAppDid,
|
|
118
|
+
oldAddress,
|
|
119
|
+
});
|
|
120
|
+
return { address: oldAddress, needsBackfill: true, source: 'migrated' };
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger.warn('wallet-migration: failed to query migrated delegation state', {
|
|
124
|
+
address: oldAddress,
|
|
125
|
+
oldAppDid,
|
|
126
|
+
error: err,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Not found
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type GetStakingAddressParams = {
|
|
136
|
+
/** Stored staking address from payment_details (if available) */
|
|
137
|
+
storedAddress?: string;
|
|
138
|
+
/** The user's DID */
|
|
139
|
+
userDid: string;
|
|
140
|
+
/** The nonce for stake address derivation (usually subscription ID) */
|
|
141
|
+
nonce: string;
|
|
142
|
+
/** OCAP client for chain queries */
|
|
143
|
+
client: OcapClient;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get the effective staking address with migration fallback
|
|
148
|
+
*
|
|
149
|
+
* Priority:
|
|
150
|
+
* 1. Use stored staking.address if provided
|
|
151
|
+
* 2. Try current wallet.address
|
|
152
|
+
* 3. Fallback to migratedFrom addresses
|
|
153
|
+
*/
|
|
154
|
+
export async function getStakingAddressWithFallback({
|
|
155
|
+
storedAddress,
|
|
156
|
+
userDid,
|
|
157
|
+
nonce,
|
|
158
|
+
client,
|
|
159
|
+
}: GetStakingAddressParams): Promise<AddressWithFallbackResult | null> {
|
|
160
|
+
// 1. Check stored staking.address first
|
|
161
|
+
if (storedAddress) {
|
|
162
|
+
return { address: storedAddress, needsBackfill: false, source: 'stored' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. Try current wallet.address
|
|
166
|
+
const currentAddress = toStakeAddress(userDid, wallet.address, nonce);
|
|
167
|
+
try {
|
|
168
|
+
const { state: currentState } = await client.getStakeState({ address: currentAddress });
|
|
169
|
+
if (currentState) {
|
|
170
|
+
return { address: currentAddress, needsBackfill: true, source: 'current' };
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
logger.warn('wallet-migration: failed to query current stake state', {
|
|
174
|
+
address: currentAddress,
|
|
175
|
+
error: err,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 3. Fallback to migratedFrom addresses
|
|
180
|
+
const migratedFrom = await getMigratedFromList(client);
|
|
181
|
+
for (const oldAppDid of migratedFrom) {
|
|
182
|
+
const oldAddress = toStakeAddress(userDid, oldAppDid, nonce);
|
|
183
|
+
try {
|
|
184
|
+
// eslint-disable-next-line no-await-in-loop
|
|
185
|
+
const { state: oldState } = await client.getStakeState({ address: oldAddress });
|
|
186
|
+
if (oldState) {
|
|
187
|
+
logger.info('wallet-migration: found stake in migratedFrom', {
|
|
188
|
+
userDid,
|
|
189
|
+
nonce,
|
|
190
|
+
oldAppDid,
|
|
191
|
+
oldAddress,
|
|
192
|
+
});
|
|
193
|
+
return { address: oldAddress, needsBackfill: true, source: 'migrated' };
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
logger.warn('wallet-migration: failed to query migrated stake state', {
|
|
197
|
+
address: oldAddress,
|
|
198
|
+
oldAppDid,
|
|
199
|
+
error: err,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Not found
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type GetAppStakeAddressParams = {
|
|
209
|
+
/** OCAP client for chain queries */
|
|
210
|
+
client: OcapClient;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get the effective staking address for gas with migration fallback
|
|
215
|
+
* This is for app's own stake (sender === receiver === wallet.address)
|
|
216
|
+
*/
|
|
217
|
+
export async function getAppStakeAddressWithFallback({
|
|
218
|
+
client,
|
|
219
|
+
}: GetAppStakeAddressParams): Promise<AddressWithFallbackResult | null> {
|
|
220
|
+
// 1. Try current wallet.address
|
|
221
|
+
const currentAddress = toStakeAddress(wallet.address, wallet.address);
|
|
222
|
+
try {
|
|
223
|
+
const { state: currentState } = await client.getStakeState({ address: currentAddress });
|
|
224
|
+
if (currentState) {
|
|
225
|
+
return { address: currentAddress, needsBackfill: false, source: 'current' };
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
logger.warn('wallet-migration: failed to query current app stake state', {
|
|
229
|
+
address: currentAddress,
|
|
230
|
+
error: err,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 2. Fallback to migratedFrom addresses
|
|
235
|
+
const migratedFrom = await getMigratedFromList(client);
|
|
236
|
+
for (const oldAppDid of migratedFrom) {
|
|
237
|
+
const oldAddress = toStakeAddress(oldAppDid, oldAppDid);
|
|
238
|
+
try {
|
|
239
|
+
// eslint-disable-next-line no-await-in-loop
|
|
240
|
+
const { state: oldState } = await client.getStakeState({ address: oldAddress });
|
|
241
|
+
if (oldState) {
|
|
242
|
+
logger.info('wallet-migration: found app stake in migratedFrom', {
|
|
243
|
+
oldAppDid,
|
|
244
|
+
oldAddress,
|
|
245
|
+
});
|
|
246
|
+
return { address: oldAddress, needsBackfill: false, source: 'migrated' };
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
logger.warn('wallet-migration: failed to query migrated app stake state', {
|
|
250
|
+
address: oldAddress,
|
|
251
|
+
oldAppDid,
|
|
252
|
+
error: err,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Not found
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Backfill delegation_address to subscription's payment_details (with optimistic locking)
|
|
263
|
+
*
|
|
264
|
+
* @param subscription - The subscription to update
|
|
265
|
+
* @param address - The delegation address to store
|
|
266
|
+
* @returns true if backfilled, false if already had a value
|
|
267
|
+
*/
|
|
268
|
+
export async function backfillDelegationAddress(subId: string, address: string): Promise<boolean> {
|
|
269
|
+
// Optimistic lock: check if already has a value
|
|
270
|
+
// Use fresh read to avoid race conditions
|
|
271
|
+
if (!subId) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const subscription = await Subscription.findByPk(subId);
|
|
276
|
+
if (!subscription) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
if (subscription.payment_details?.arcblock?.delegation_address) {
|
|
280
|
+
// Already backfilled by another request
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Backfill the address (only if arcblock payment_details exists with required fields)
|
|
285
|
+
const existingArcblock = subscription.payment_details?.arcblock;
|
|
286
|
+
if (!existingArcblock?.tx_hash || !existingArcblock?.payer) {
|
|
287
|
+
logger.warn('wallet-migration: cannot backfill delegation_address - missing arcblock payment_details', {
|
|
288
|
+
subscriptionId: subscription.id,
|
|
289
|
+
});
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await subscription.update({
|
|
294
|
+
payment_details: {
|
|
295
|
+
...subscription.payment_details,
|
|
296
|
+
arcblock: {
|
|
297
|
+
...existingArcblock,
|
|
298
|
+
delegation_address: address,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
logger.info('wallet-migration: backfilled delegation_address', {
|
|
304
|
+
subscriptionId: subscription.id,
|
|
305
|
+
address,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
return true;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
logger.error('wallet-migration: failed to backfill delegation_address', {
|
|
311
|
+
subscriptionId: subId,
|
|
312
|
+
address,
|
|
313
|
+
error: err,
|
|
314
|
+
});
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -725,7 +725,7 @@ const handleStakeSlash = async (
|
|
|
725
725
|
return;
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
-
const address = await getSubscriptionStakeAddress(subscription, customer.did);
|
|
728
|
+
const address = await getSubscriptionStakeAddress(subscription, customer.did, paymentMethod);
|
|
729
729
|
const slashAmount = paymentIntent.amount;
|
|
730
730
|
const stakeEnough = await checkRemainingStake(paymentMethod, paymentCurrency, address, slashAmount);
|
|
731
731
|
if (!stakeEnough.enough) {
|
|
@@ -859,6 +859,8 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
859
859
|
}
|
|
860
860
|
|
|
861
861
|
const invoice = await Invoice.findByPk(paymentIntent.invoice_id);
|
|
862
|
+
// Fetch subscription early for migration fallback support and overdraft protection check
|
|
863
|
+
const subscription = invoice?.subscription_id ? await Subscription.findByPk(invoice.subscription_id) : null;
|
|
862
864
|
|
|
863
865
|
if (invoice?.status === 'void') {
|
|
864
866
|
await paymentIntent.update({
|
|
@@ -870,20 +872,17 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
870
872
|
return;
|
|
871
873
|
}
|
|
872
874
|
|
|
873
|
-
if (
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
});
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
875
|
+
if (
|
|
876
|
+
subscription &&
|
|
877
|
+
subscription.isActive() &&
|
|
878
|
+
subscription.overdraft_protection?.enabled &&
|
|
879
|
+
invoice?.billing_reason === 'overdraft_protection'
|
|
880
|
+
) {
|
|
881
|
+
logger.info('PaymentIntent capture skipped because of overdraft protection', {
|
|
882
|
+
id: paymentIntent.id,
|
|
883
|
+
subscription: subscription.id,
|
|
884
|
+
});
|
|
885
|
+
return;
|
|
887
886
|
}
|
|
888
887
|
|
|
889
888
|
// check max retry before doing any hard work
|
|
@@ -1006,18 +1005,19 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
1006
1005
|
paymentCurrency,
|
|
1007
1006
|
userDid: payer || customer.did,
|
|
1008
1007
|
amount: paymentIntent.amount,
|
|
1008
|
+
subscription,
|
|
1009
1009
|
});
|
|
1010
1010
|
|
|
1011
1011
|
if (result.sufficient === false) {
|
|
1012
1012
|
logger.error('PaymentIntent capture aborted - insufficient balance/delegation', {
|
|
1013
1013
|
paymentIntentId: paymentIntent.id,
|
|
1014
1014
|
invoiceId: paymentIntent.invoice_id,
|
|
1015
|
-
failureReason:
|
|
1015
|
+
failureReason: result.reason,
|
|
1016
1016
|
result,
|
|
1017
1017
|
queueDelayMs,
|
|
1018
1018
|
delaySeconds: Math.round(queueDelayMs / 1000),
|
|
1019
1019
|
});
|
|
1020
|
-
throw new CustomError(
|
|
1020
|
+
throw new CustomError(result.reason, 'Payer balance or delegation not sufficient for this payment');
|
|
1021
1021
|
}
|
|
1022
1022
|
|
|
1023
1023
|
const signed = await client.signTransferV2Tx({
|
|
@@ -1245,7 +1245,6 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
1245
1245
|
logger.warn('catch:PaymentIntent capture failed', { id: paymentIntent.id, attemptCount, minRetryMail, invoice });
|
|
1246
1246
|
|
|
1247
1247
|
if (needSendMail && invoice.billing_reason === 'subscription_cycle') {
|
|
1248
|
-
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
1249
1248
|
if (subscription) {
|
|
1250
1249
|
await subscription.update({
|
|
1251
1250
|
metadata: Object.assign(subscription.metadata, {
|
package/api/src/queues/refund.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isRefundReasonSupportedByStripe } from '../libs/refund';
|
|
|
3
3
|
import { checkRemainingStake, getSubscriptionStakeAddress } from '../libs/subscription';
|
|
4
4
|
import { sendErc20ToUser } from '../integrations/ethereum/token';
|
|
5
5
|
import { wallet } from '../libs/auth';
|
|
6
|
-
import CustomError from '../libs/error';
|
|
6
|
+
import CustomError, { NonRetryableError } from '../libs/error';
|
|
7
7
|
import { events } from '../libs/event';
|
|
8
8
|
import logger from '../libs/logger';
|
|
9
9
|
import { getGasPayerExtra, isBalanceSufficientForRefund } from '../libs/payment';
|
|
@@ -35,6 +35,21 @@ type Updates = {
|
|
|
35
35
|
};
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
const markRefundNonRetryable = async (refund: Refund, code: string, message: string, paymentMethod?: PaymentMethod) => {
|
|
39
|
+
await refund.update({
|
|
40
|
+
status: 'requires_action',
|
|
41
|
+
last_attempt_error: {
|
|
42
|
+
type: 'invalid_request_error',
|
|
43
|
+
code,
|
|
44
|
+
message,
|
|
45
|
+
payment_method_id: paymentMethod?.id,
|
|
46
|
+
payment_method_type: paymentMethod?.type,
|
|
47
|
+
},
|
|
48
|
+
attempt_count: refund.attempt_count + 1,
|
|
49
|
+
attempted: true,
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
38
53
|
export const handleRefundFailed = (refund: Refund, error: PaymentError) => {
|
|
39
54
|
const attemptCount = refund.attempt_count + 1;
|
|
40
55
|
const updates: Updates = {
|
|
@@ -94,24 +109,27 @@ export const handleRefund = async (job: RefundJob) => {
|
|
|
94
109
|
const paymentCurrency = await PaymentCurrency.findByPk(refund.currency_id);
|
|
95
110
|
if (!paymentCurrency) {
|
|
96
111
|
logger.warn(`PaymentCurrency not found: ${refund.currency_id}`);
|
|
112
|
+
await markRefundNonRetryable(refund, 'CURRENCY_NOT_FOUND', 'Payment currency not found');
|
|
97
113
|
return;
|
|
98
114
|
}
|
|
99
115
|
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
100
116
|
if (!paymentMethod) {
|
|
101
117
|
logger.warn(`PaymentMethod not found: ${paymentCurrency.payment_method_id}`);
|
|
118
|
+
await markRefundNonRetryable(refund, 'PAYMENT_METHOD_NOT_FOUND', 'Payment method not found');
|
|
102
119
|
return;
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
const customer = await Customer.findByPk(refund.customer_id);
|
|
106
123
|
if (!customer) {
|
|
107
124
|
logger.warn(`Customer not found: ${refund.customer_id}`);
|
|
125
|
+
await markRefundNonRetryable(refund, 'CUSTOMER_NOT_FOUND', 'Customer not found', paymentMethod);
|
|
108
126
|
return;
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
if (refund?.type === 'stake_return') {
|
|
112
|
-
handleStakeReturnJob(job, refund, paymentCurrency, paymentMethod, customer);
|
|
130
|
+
await handleStakeReturnJob(job, refund, paymentCurrency, paymentMethod, customer);
|
|
113
131
|
} else if (paymentMethod) {
|
|
114
|
-
handleRefundJob(job, refund, paymentCurrency, paymentMethod, customer);
|
|
132
|
+
await handleRefundJob(job, refund, paymentCurrency, paymentMethod, customer);
|
|
115
133
|
}
|
|
116
134
|
};
|
|
117
135
|
|
|
@@ -164,18 +182,26 @@ const handleRefundJob = async (
|
|
|
164
182
|
const refundSupport = ['arcblock', 'ethereum', 'stripe', 'base'].includes(paymentMethod.type);
|
|
165
183
|
if (refundSupport === false) {
|
|
166
184
|
logger.warn(`PaymentMethod does not support auto charge: ${paymentCurrency.payment_method_id}`);
|
|
185
|
+
await markRefundNonRetryable(
|
|
186
|
+
refund,
|
|
187
|
+
'PAYMENT_METHOD_NOT_SUPPORTED',
|
|
188
|
+
'Payment method does not support refund',
|
|
189
|
+
paymentMethod
|
|
190
|
+
);
|
|
167
191
|
return;
|
|
168
192
|
}
|
|
169
193
|
|
|
170
194
|
// try refund transfer and reschedule on error
|
|
171
195
|
logger.info('Refund transfer attempt', { id: refund.id, attempt: refund.attempt_count });
|
|
172
|
-
let result;
|
|
173
|
-
const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
|
|
174
|
-
if (!paymentIntent) {
|
|
175
|
-
throw new Error('PaymentIntent not found');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
196
|
try {
|
|
197
|
+
if (!refund.payment_intent_id) {
|
|
198
|
+
throw new NonRetryableError('PAYMENT_INTENT_NOT_FOUND', 'payment_intent_id is missing for refund');
|
|
199
|
+
}
|
|
200
|
+
const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
|
|
201
|
+
if (!paymentIntent) {
|
|
202
|
+
throw new NonRetryableError('PAYMENT_INTENT_NOT_FOUND', 'PaymentIntent not found');
|
|
203
|
+
}
|
|
204
|
+
let result;
|
|
179
205
|
if (paymentMethod.type === 'arcblock') {
|
|
180
206
|
const client = paymentMethod.getOcapClient();
|
|
181
207
|
// check balance before transfer with transaction
|
|
@@ -288,6 +314,12 @@ const handleRefundJob = async (
|
|
|
288
314
|
} catch (err) {
|
|
289
315
|
logger.error('refund transfer failed', { error: err, id: refund.id });
|
|
290
316
|
|
|
317
|
+
if (err instanceof NonRetryableError || err?.nonRetryable === true) {
|
|
318
|
+
await markRefundNonRetryable(refund, err.code, err.message, paymentMethod);
|
|
319
|
+
logger.error('refund transfer aborted: non-retryable error', { id: refund.id, code: err.code });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
291
323
|
const error: PaymentError = {
|
|
292
324
|
type: 'card_error',
|
|
293
325
|
code: err.code,
|
|
@@ -348,7 +380,8 @@ const handleStakeReturnJob = async (
|
|
|
348
380
|
const client = paymentMethod.getOcapClient();
|
|
349
381
|
const subscription = await Subscription.findByPk(refund.subscription_id);
|
|
350
382
|
const address =
|
|
351
|
-
arcblockDetail?.staking?.address ||
|
|
383
|
+
arcblockDetail?.staking?.address ||
|
|
384
|
+
(await getSubscriptionStakeAddress(subscription!, customer.did, paymentMethod));
|
|
352
385
|
const stakeEnough = await checkRemainingStake(paymentMethod, paymentCurrency, address, refund.amount);
|
|
353
386
|
if (!stakeEnough.enough) {
|
|
354
387
|
logger.warn('Stake return aborted because stake is not enough ', {
|
|
@@ -814,6 +814,7 @@ const handleFinalInvoicePayment = async (
|
|
|
814
814
|
paymentCurrency,
|
|
815
815
|
userDid: payer || customer.did,
|
|
816
816
|
amount: lastInvoice.amount_remaining,
|
|
817
|
+
subscription,
|
|
817
818
|
});
|
|
818
819
|
|
|
819
820
|
if (!hasSufficientBalance.sufficient) {
|
|
@@ -898,7 +899,7 @@ export const handleStakeSlashAfterCancel = async (subscription: Subscription, fo
|
|
|
898
899
|
|
|
899
900
|
// check the staking
|
|
900
901
|
const client = method.getOcapClient();
|
|
901
|
-
const address = await getSubscriptionStakeAddress(subscription, customer.did);
|
|
902
|
+
const address = await getSubscriptionStakeAddress(subscription, customer.did, method);
|
|
902
903
|
const { state } = await client.getStakeState({ address });
|
|
903
904
|
if (!state || !state.data?.value) {
|
|
904
905
|
logger.warn('Stake slashing aborted because no staking state', {
|
|
@@ -1142,7 +1143,7 @@ const slashStakeOnCancel = async (subscription: Subscription) => {
|
|
|
1142
1143
|
});
|
|
1143
1144
|
return;
|
|
1144
1145
|
}
|
|
1145
|
-
const address = await getSubscriptionStakeAddress(subscription, customer.did);
|
|
1146
|
+
const address = await getSubscriptionStakeAddress(subscription, customer.did, paymentMethod);
|
|
1146
1147
|
const result = await getSubscriptionStakeSlashSetup(subscription, address, paymentMethod);
|
|
1147
1148
|
const stakeEnough = await checkRemainingStake(paymentMethod, currency, address, result.return_amount);
|
|
1148
1149
|
if (!stakeEnough.enough) {
|
|
@@ -3133,7 +3133,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
3133
3133
|
});
|
|
3134
3134
|
|
|
3135
3135
|
const isPayment = checkoutSession.mode === 'payment';
|
|
3136
|
-
let canFastPay = isPayment && canPayWithDelegation(paymentIntent?.beneficiaries || []);
|
|
3136
|
+
let canFastPay = isPayment && (await canPayWithDelegation(paymentIntent?.beneficiaries || [], paymentMethod));
|
|
3137
3137
|
let fastPayInfo = null;
|
|
3138
3138
|
let delegation: SufficientForPaymentResult | null = null;
|
|
3139
3139
|
let creditSufficient = false;
|
|
@@ -3651,7 +3651,7 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
3651
3651
|
|
|
3652
3652
|
const isPayment = checkoutSession.mode === 'payment';
|
|
3653
3653
|
let fastPaid = false;
|
|
3654
|
-
let canFastPay = isPayment && canPayWithDelegation(paymentIntent?.beneficiaries || []);
|
|
3654
|
+
let canFastPay = isPayment && (await canPayWithDelegation(paymentIntent?.beneficiaries || [], paymentMethod));
|
|
3655
3655
|
let delegation: SufficientForPaymentResult | null = null;
|
|
3656
3656
|
|
|
3657
3657
|
// Handle credit payment directly
|
|
@@ -1631,6 +1631,7 @@ export async function executeOcapTransactions(
|
|
|
1631
1631
|
tx_hash: delegationTxHash,
|
|
1632
1632
|
payer: userDid,
|
|
1633
1633
|
type: 'delegate',
|
|
1634
|
+
delegation_address: delegation?.meta?.address, // Store delegation address for migration compatibility
|
|
1634
1635
|
staking: {
|
|
1635
1636
|
tx_hash: stakingTxHash,
|
|
1636
1637
|
address: await getCustomerStakeAddress(userDid, nonce || subscriptionId || ''),
|
|
@@ -1592,6 +1592,7 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1592
1592
|
paymentCurrency,
|
|
1593
1593
|
userDid: payer,
|
|
1594
1594
|
amount: due || '0',
|
|
1595
|
+
subscription,
|
|
1595
1596
|
});
|
|
1596
1597
|
logger.info('delegation sufficient for payment', {
|
|
1597
1598
|
subscription: subscription.id,
|
|
@@ -2214,6 +2215,7 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2214
2215
|
paymentCurrency,
|
|
2215
2216
|
userDid: payer,
|
|
2216
2217
|
amount: requiredAmount,
|
|
2218
|
+
subscription,
|
|
2217
2219
|
});
|
|
2218
2220
|
|
|
2219
2221
|
if (!delegation.sufficient) {
|
|
@@ -2485,6 +2487,7 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
2485
2487
|
paymentCurrency,
|
|
2486
2488
|
userDid: payer,
|
|
2487
2489
|
amount: requiredAmount,
|
|
2490
|
+
subscription,
|
|
2488
2491
|
});
|
|
2489
2492
|
const noStake = subscription.billing_thresholds?.no_stake;
|
|
2490
2493
|
if (paymentMethod.type === 'arcblock' && delegation.sufficient && !noStake) {
|
|
@@ -2901,6 +2904,7 @@ router.get('/:id/delegation', authPortal, async (req, res) => {
|
|
|
2901
2904
|
paymentCurrency: subscription.paymentCurrency,
|
|
2902
2905
|
userDid: payer as string,
|
|
2903
2906
|
amount: '0',
|
|
2907
|
+
subscription,
|
|
2904
2908
|
});
|
|
2905
2909
|
if (
|
|
2906
2910
|
!delegator.sufficient &&
|
|
@@ -330,6 +330,7 @@ export type PaymentDetails = {
|
|
|
330
330
|
payer: string;
|
|
331
331
|
type?: LiteralUnion<'slash' | 'transfer' | 'delegate' | 'stake_return' | 'credit', string>;
|
|
332
332
|
receiver?: string;
|
|
333
|
+
delegation_address?: string; // The delegation address at the time of authorization (for migration compatibility)
|
|
333
334
|
staking?: {
|
|
334
335
|
tx_hash: string;
|
|
335
336
|
address: string;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { toDelegateAddress, toStakeAddress } from '@arcblock/did-util';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getMigratedFromList,
|
|
5
|
+
getDelegationAddressWithFallback,
|
|
6
|
+
getStakingAddressWithFallback,
|
|
7
|
+
getAppStakeAddressWithFallback,
|
|
8
|
+
clearMigratedFromCache,
|
|
9
|
+
} from '../../src/libs/wallet-migration';
|
|
10
|
+
|
|
11
|
+
// Mock the auth module
|
|
12
|
+
jest.mock('../../src/libs/auth', () => ({
|
|
13
|
+
wallet: {
|
|
14
|
+
address: 'zNKcF7wyrvAvn4YaADjr4p9gpFUSHnW2aQJa', // Current app DID
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock logger
|
|
19
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
20
|
+
info: jest.fn(),
|
|
21
|
+
warn: jest.fn(),
|
|
22
|
+
error: jest.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe('wallet-migration', () => {
|
|
26
|
+
// Mock OcapClient
|
|
27
|
+
const createMockClient = (overrides: any = {}) => ({
|
|
28
|
+
getAccountState: jest.fn().mockResolvedValue({
|
|
29
|
+
state: {
|
|
30
|
+
migratedFrom: ['zNKq13Dr2TBHELpLDUJFGxepiGP7YHbAVxPn', 'zNKq6yG8AVbwdRBDJSNCDVJJUQD1AXdkK7Wp'],
|
|
31
|
+
address: 'zNKcF7wyrvAvn4YaADjr4p9gpFUSHnW2aQJa',
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
getDelegateState: jest.fn().mockResolvedValue({ state: null }),
|
|
35
|
+
getStakeState: jest.fn().mockResolvedValue({ state: null }),
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
clearMigratedFromCache();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('getMigratedFromList', () => {
|
|
44
|
+
it('should return migratedFrom list from chain', async () => {
|
|
45
|
+
const mockClient = createMockClient();
|
|
46
|
+
const result = await getMigratedFromList(mockClient as any);
|
|
47
|
+
expect(result).toEqual(['zNKq13Dr2TBHELpLDUJFGxepiGP7YHbAVxPn', 'zNKq6yG8AVbwdRBDJSNCDVJJUQD1AXdkK7Wp']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should cache the result and not query again', async () => {
|
|
51
|
+
const mockClient = createMockClient();
|
|
52
|
+
await getMigratedFromList(mockClient as any);
|
|
53
|
+
await getMigratedFromList(mockClient as any);
|
|
54
|
+
expect(mockClient.getAccountState).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return empty array if no migratedFrom', async () => {
|
|
58
|
+
const mockClient = createMockClient({
|
|
59
|
+
getAccountState: jest.fn().mockResolvedValue({ state: {} }),
|
|
60
|
+
});
|
|
61
|
+
const result = await getMigratedFromList(mockClient as any);
|
|
62
|
+
expect(result).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return empty array on error', async () => {
|
|
66
|
+
const mockClient = createMockClient({
|
|
67
|
+
getAccountState: jest.fn().mockRejectedValue(new Error('Network error')),
|
|
68
|
+
});
|
|
69
|
+
const result = await getMigratedFromList(mockClient as any);
|
|
70
|
+
expect(result).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('getDelegationAddressWithFallback', () => {
|
|
75
|
+
const delegator = 'zNKtCNqYWLYWYW3gWRA1vnRNdWLrFXfPxRKW';
|
|
76
|
+
const currentWalletAddress = 'zNKcF7wyrvAvn4YaADjr4p9gpFUSHnW2aQJa';
|
|
77
|
+
const oldAppDid = 'zNKq13Dr2TBHELpLDUJFGxepiGP7YHbAVxPn';
|
|
78
|
+
|
|
79
|
+
it('should return stored address if available', async () => {
|
|
80
|
+
const mockClient = createMockClient();
|
|
81
|
+
|
|
82
|
+
const result = await getDelegationAddressWithFallback({
|
|
83
|
+
storedAddress: 'stored_delegation_address',
|
|
84
|
+
delegator,
|
|
85
|
+
client: mockClient as any,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result).toEqual({
|
|
89
|
+
address: 'stored_delegation_address',
|
|
90
|
+
needsBackfill: false,
|
|
91
|
+
source: 'stored',
|
|
92
|
+
});
|
|
93
|
+
// Should not query chain if stored
|
|
94
|
+
expect(mockClient.getDelegateState).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return current address if delegation exists on current wallet', async () => {
|
|
98
|
+
const currentAddress = toDelegateAddress(delegator, currentWalletAddress);
|
|
99
|
+
const mockClient = createMockClient({
|
|
100
|
+
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
101
|
+
if (address === currentAddress) {
|
|
102
|
+
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
103
|
+
}
|
|
104
|
+
return { state: null };
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = await getDelegationAddressWithFallback({
|
|
109
|
+
delegator,
|
|
110
|
+
client: mockClient as any,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result).toEqual({
|
|
114
|
+
address: currentAddress,
|
|
115
|
+
needsBackfill: true,
|
|
116
|
+
source: 'current',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should fallback to migratedFrom addresses', async () => {
|
|
121
|
+
const oldAddress = toDelegateAddress(delegator, oldAppDid);
|
|
122
|
+
const mockClient = createMockClient({
|
|
123
|
+
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
124
|
+
if (address === oldAddress) {
|
|
125
|
+
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
126
|
+
}
|
|
127
|
+
return { state: null };
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const result = await getDelegationAddressWithFallback({
|
|
132
|
+
delegator,
|
|
133
|
+
client: mockClient as any,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result).toEqual({
|
|
137
|
+
address: oldAddress,
|
|
138
|
+
needsBackfill: true,
|
|
139
|
+
source: 'migrated',
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should return null if no delegation found', async () => {
|
|
144
|
+
const mockClient = createMockClient();
|
|
145
|
+
|
|
146
|
+
const result = await getDelegationAddressWithFallback({
|
|
147
|
+
delegator,
|
|
148
|
+
client: mockClient as any,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('getStakingAddressWithFallback', () => {
|
|
156
|
+
const userDid = 'zNKtCNqYWLYWYW3gWRA1vnRNdWLrFXfPxRKW';
|
|
157
|
+
const nonce = 'sub_123';
|
|
158
|
+
const currentWalletAddress = 'zNKcF7wyrvAvn4YaADjr4p9gpFUSHnW2aQJa';
|
|
159
|
+
const oldAppDid = 'zNKq13Dr2TBHELpLDUJFGxepiGP7YHbAVxPn';
|
|
160
|
+
|
|
161
|
+
it('should return stored address if available', async () => {
|
|
162
|
+
const mockClient = createMockClient();
|
|
163
|
+
|
|
164
|
+
const result = await getStakingAddressWithFallback({
|
|
165
|
+
storedAddress: 'stored_stake_address',
|
|
166
|
+
userDid,
|
|
167
|
+
nonce,
|
|
168
|
+
client: mockClient as any,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result).toEqual({
|
|
172
|
+
address: 'stored_stake_address',
|
|
173
|
+
needsBackfill: false,
|
|
174
|
+
source: 'stored',
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should return current address if stake exists on current wallet', async () => {
|
|
179
|
+
const currentAddress = toStakeAddress(userDid, currentWalletAddress, nonce);
|
|
180
|
+
const mockClient = createMockClient({
|
|
181
|
+
getStakeState: jest.fn().mockImplementation(({ address }) => {
|
|
182
|
+
if (address === currentAddress) {
|
|
183
|
+
return { state: { tokens: [] } };
|
|
184
|
+
}
|
|
185
|
+
return { state: null };
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const result = await getStakingAddressWithFallback({
|
|
190
|
+
userDid,
|
|
191
|
+
nonce,
|
|
192
|
+
client: mockClient as any,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(result).toEqual({
|
|
196
|
+
address: currentAddress,
|
|
197
|
+
needsBackfill: true,
|
|
198
|
+
source: 'current',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should fallback to migratedFrom addresses', async () => {
|
|
203
|
+
const oldAddress = toStakeAddress(userDid, oldAppDid, nonce);
|
|
204
|
+
const mockClient = createMockClient({
|
|
205
|
+
getStakeState: jest.fn().mockImplementation(({ address }) => {
|
|
206
|
+
if (address === oldAddress) {
|
|
207
|
+
return { state: { tokens: [] } };
|
|
208
|
+
}
|
|
209
|
+
return { state: null };
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const result = await getStakingAddressWithFallback({
|
|
214
|
+
userDid,
|
|
215
|
+
nonce,
|
|
216
|
+
client: mockClient as any,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(result).toEqual({
|
|
220
|
+
address: oldAddress,
|
|
221
|
+
needsBackfill: true,
|
|
222
|
+
source: 'migrated',
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('getAppStakeAddressWithFallback', () => {
|
|
228
|
+
const currentWalletAddress = 'zNKcF7wyrvAvn4YaADjr4p9gpFUSHnW2aQJa';
|
|
229
|
+
const oldAppDid = 'zNKq13Dr2TBHELpLDUJFGxepiGP7YHbAVxPn';
|
|
230
|
+
|
|
231
|
+
it('should return current address if app stake exists', async () => {
|
|
232
|
+
const currentAddress = toStakeAddress(currentWalletAddress, currentWalletAddress);
|
|
233
|
+
const mockClient = createMockClient({
|
|
234
|
+
getStakeState: jest.fn().mockImplementation(({ address }) => {
|
|
235
|
+
if (address === currentAddress) {
|
|
236
|
+
return { state: { tokens: [] } };
|
|
237
|
+
}
|
|
238
|
+
return { state: null };
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const result = await getAppStakeAddressWithFallback({ client: mockClient as any });
|
|
243
|
+
|
|
244
|
+
expect(result).toEqual({
|
|
245
|
+
address: currentAddress,
|
|
246
|
+
needsBackfill: false,
|
|
247
|
+
source: 'current',
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should fallback to migratedFrom addresses for app stake', async () => {
|
|
252
|
+
const oldAddress = toStakeAddress(oldAppDid, oldAppDid);
|
|
253
|
+
const mockClient = createMockClient({
|
|
254
|
+
getStakeState: jest.fn().mockImplementation(({ address }) => {
|
|
255
|
+
if (address === oldAddress) {
|
|
256
|
+
return { state: { tokens: [] } };
|
|
257
|
+
}
|
|
258
|
+
return { state: null };
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const result = await getAppStakeAddressWithFallback({ client: mockClient as any });
|
|
263
|
+
|
|
264
|
+
expect(result).toEqual({
|
|
265
|
+
address: oldAddress,
|
|
266
|
+
needsBackfill: false,
|
|
267
|
+
source: 'migrated',
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.2",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"@blocklet/error": "^0.3.5",
|
|
60
60
|
"@blocklet/js-sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
61
61
|
"@blocklet/logger": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
62
|
-
"@blocklet/payment-broker-client": "1.25.
|
|
63
|
-
"@blocklet/payment-react": "1.25.
|
|
64
|
-
"@blocklet/payment-vendor": "1.25.
|
|
62
|
+
"@blocklet/payment-broker-client": "1.25.2",
|
|
63
|
+
"@blocklet/payment-react": "1.25.2",
|
|
64
|
+
"@blocklet/payment-vendor": "1.25.2",
|
|
65
65
|
"@blocklet/sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
66
66
|
"@blocklet/ui-react": "^3.4.7",
|
|
67
67
|
"@blocklet/uploader": "^0.3.19",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"devDependencies": {
|
|
133
133
|
"@abtnode/types": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
134
134
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
135
|
-
"@blocklet/payment-types": "1.25.
|
|
135
|
+
"@blocklet/payment-types": "1.25.2",
|
|
136
136
|
"@types/cookie-parser": "^1.4.9",
|
|
137
137
|
"@types/cors": "^2.8.19",
|
|
138
138
|
"@types/debug": "^4.1.12",
|
|
@@ -179,5 +179,5 @@
|
|
|
179
179
|
"parser": "typescript"
|
|
180
180
|
}
|
|
181
181
|
},
|
|
182
|
-
"gitHead": "
|
|
182
|
+
"gitHead": "11621a1c925f94bf4f73644de70088e5f0580f74"
|
|
183
183
|
}
|