feat(wallet/mpc): 增强提现和充值流程可靠性
## 主要改进 ### MPC 签名系统 (mpc-system) - 添加签名缓存机制,避免重复签名请求 - 修复 yParity 恢复逻辑,确保签名格式正确 - 优化签名完成报告流程 ### 区块链服务 (blockchain-service) - EIP-1559 降级为 Legacy 交易(KAVA 测试网兼容) - 修复 gas 估算逻辑 ### 钱包服务 (wallet-service) - 添加乐观锁机制 (version 字段) 防止并发修改 - 提现确认流程添加事务保护 + 乐观锁 - 提现失败时正确解冻 amount + fee - 充值流程添加事务保护 + 乐观锁 - Kafka consumer 添加错误重抛,触发重试机制 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bbd6f2ee38
commit
5c931ccdd8
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,59 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SignatureCacheTTL is the time-to-live for cached signatures (24 hours)
|
||||||
|
SignatureCacheTTL = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// signatureCacheEntry represents a cached signature with expiration
|
||||||
|
type signatureCacheEntry struct {
|
||||||
|
Signature []byte
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignatureCache provides in-memory caching for signatures
|
||||||
|
type SignatureCache struct {
|
||||||
|
cache sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global signature cache instance
|
||||||
|
var globalSignatureCache = &SignatureCache{}
|
||||||
|
|
||||||
|
// GetSignatureCache returns the global signature cache instance
|
||||||
|
func GetSignatureCache() *SignatureCache {
|
||||||
|
return globalSignatureCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a signature in the cache with 24h TTL
|
||||||
|
func (c *SignatureCache) Set(sessionID uuid.UUID, signature []byte) {
|
||||||
|
entry := signatureCacheEntry{
|
||||||
|
Signature: signature,
|
||||||
|
ExpiresAt: time.Now().Add(SignatureCacheTTL),
|
||||||
|
}
|
||||||
|
c.cache.Store(sessionID.String(), entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a signature from the cache
|
||||||
|
func (c *SignatureCache) Get(sessionID uuid.UUID) ([]byte, bool) {
|
||||||
|
value, ok := c.cache.Load(sessionID.String())
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := value.(signatureCacheEntry)
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if time.Now().After(entry.ExpiresAt) {
|
||||||
|
c.cache.Delete(sessionID.String())
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Signature, true
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/rwadurian/mpc-system/services/session-coordinator/adapters/output/memory"
|
||||||
"github.com/rwadurian/mpc-system/services/session-coordinator/application/ports/input"
|
"github.com/rwadurian/mpc-system/services/session-coordinator/application/ports/input"
|
||||||
|
"github.com/rwadurian/mpc-system/services/session-coordinator/domain/entities"
|
||||||
"github.com/rwadurian/mpc-system/services/session-coordinator/domain/repositories"
|
"github.com/rwadurian/mpc-system/services/session-coordinator/domain/repositories"
|
||||||
"github.com/rwadurian/mpc-system/services/session-coordinator/domain/value_objects"
|
"github.com/rwadurian/mpc-system/services/session-coordinator/domain/value_objects"
|
||||||
)
|
)
|
||||||
|
|
@ -45,7 +47,13 @@ func (uc *GetSessionStatusUseCase) Execute(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Build response
|
// 3. For completed sign sessions, get signature from cache
|
||||||
|
var signature []byte
|
||||||
|
if session.SessionType == entities.SessionTypeSign && session.Status.String() == "completed" {
|
||||||
|
signature, _ = memory.GetSignatureCache().Get(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build response
|
||||||
// has_delegate is only meaningful for keygen sessions
|
// has_delegate is only meaningful for keygen sessions
|
||||||
hasDelegate := session.DelegatePartyID != "" && string(session.SessionType) == "keygen"
|
hasDelegate := session.DelegatePartyID != "" && string(session.SessionType) == "keygen"
|
||||||
return &input.SessionStatusOutput{
|
return &input.SessionStatusOutput{
|
||||||
|
|
@ -56,6 +64,7 @@ func (uc *GetSessionStatusUseCase) Execute(
|
||||||
ThresholdN: session.Threshold.N(),
|
ThresholdN: session.Threshold.N(),
|
||||||
Participants: participants,
|
Participants: participants,
|
||||||
PublicKey: session.PublicKey,
|
PublicKey: session.PublicKey,
|
||||||
|
Signature: signature,
|
||||||
HasDelegate: hasDelegate,
|
HasDelegate: hasDelegate,
|
||||||
DelegatePartyID: session.DelegatePartyID,
|
DelegatePartyID: session.DelegatePartyID,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rwadurian/mpc-system/pkg/logger"
|
"github.com/rwadurian/mpc-system/pkg/logger"
|
||||||
|
"github.com/rwadurian/mpc-system/services/session-coordinator/adapters/output/memory"
|
||||||
"github.com/rwadurian/mpc-system/services/session-coordinator/application/ports/input"
|
"github.com/rwadurian/mpc-system/services/session-coordinator/application/ports/input"
|
||||||
"github.com/rwadurian/mpc-system/services/session-coordinator/application/ports/output"
|
"github.com/rwadurian/mpc-system/services/session-coordinator/application/ports/output"
|
||||||
"github.com/rwadurian/mpc-system/services/session-coordinator/domain/entities"
|
"github.com/rwadurian/mpc-system/services/session-coordinator/domain/entities"
|
||||||
|
|
@ -169,6 +170,15 @@ func (uc *ReportCompletionUseCase) executeWithRetry(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For sign sessions, cache the signature for HTTP API retrieval
|
||||||
|
// Note: server-party sends signature in PublicKey field (same field used for keygen public key)
|
||||||
|
if session.SessionType == entities.SessionTypeSign && len(inputData.PublicKey) > 0 {
|
||||||
|
memory.GetSignatureCache().Set(session.ID.UUID(), inputData.PublicKey)
|
||||||
|
logger.Info("cached signature for sign session",
|
||||||
|
zap.String("session_id", session.ID.String()),
|
||||||
|
zap.Int("signature_len", len(inputData.PublicKey)))
|
||||||
|
}
|
||||||
|
|
||||||
// Publish session completed event
|
// Publish session completed event
|
||||||
completedEvent := output.SessionCompletedEvent{
|
completedEvent := output.SessionCompletedEvent{
|
||||||
SessionID: session.ID.String(),
|
SessionID: session.ID.String(),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
formatUnits,
|
formatUnits,
|
||||||
Transaction,
|
Transaction,
|
||||||
Signature,
|
Signature,
|
||||||
|
recoverAddress,
|
||||||
} from 'ethers';
|
} from 'ethers';
|
||||||
import { ChainConfigService } from './chain-config.service';
|
import { ChainConfigService } from './chain-config.service';
|
||||||
import { ChainType } from '@/domain/value-objects';
|
import { ChainType } from '@/domain/value-objects';
|
||||||
|
|
@ -191,16 +192,42 @@ export class Erc20TransferService {
|
||||||
data: transferData,
|
data: transferData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tx = Transaction.from({
|
const gasLimit = gasEstimate * BigInt(120) / BigInt(100); // 增加 20% buffer
|
||||||
type: 2, // EIP-1559
|
|
||||||
chainId: config.chainId,
|
// 检测链是否支持 EIP-1559
|
||||||
nonce,
|
// 如果 maxFeePerGas 为 null 或 0,则使用 legacy 交易
|
||||||
to: config.usdtContract,
|
const supportsEip1559 = feeData.maxFeePerGas && feeData.maxFeePerGas > BigInt(0);
|
||||||
data: transferData,
|
this.logger.log(`[TRANSFER] Chain supports EIP-1559: ${supportsEip1559}`);
|
||||||
gasLimit: gasEstimate * BigInt(120) / BigInt(100), // 增加 20% buffer
|
this.logger.log(`[TRANSFER] Fee data: gasPrice=${feeData.gasPrice}, maxFeePerGas=${feeData.maxFeePerGas}`);
|
||||||
maxFeePerGas: feeData.maxFeePerGas,
|
|
||||||
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
let tx: Transaction;
|
||||||
});
|
if (supportsEip1559) {
|
||||||
|
// EIP-1559 交易 (type 2)
|
||||||
|
tx = Transaction.from({
|
||||||
|
type: 2,
|
||||||
|
chainId: config.chainId,
|
||||||
|
nonce,
|
||||||
|
to: config.usdtContract,
|
||||||
|
data: transferData,
|
||||||
|
gasLimit,
|
||||||
|
maxFeePerGas: feeData.maxFeePerGas,
|
||||||
|
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
||||||
|
});
|
||||||
|
this.logger.log(`[TRANSFER] Built EIP-1559 transaction`);
|
||||||
|
} else {
|
||||||
|
// Legacy 交易 (type 0)
|
||||||
|
const gasPrice = feeData.gasPrice || BigInt(1000000000); // 默认 1 gwei
|
||||||
|
tx = Transaction.from({
|
||||||
|
type: 0,
|
||||||
|
chainId: config.chainId,
|
||||||
|
nonce,
|
||||||
|
to: config.usdtContract,
|
||||||
|
data: transferData,
|
||||||
|
gasLimit,
|
||||||
|
gasPrice,
|
||||||
|
});
|
||||||
|
this.logger.log(`[TRANSFER] Built legacy transaction with gasPrice=${gasPrice}`);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`[TRANSFER] Transaction built: nonce=${nonce}, gasLimit=${tx.gasLimit}`);
|
this.logger.log(`[TRANSFER] Transaction built: nonce=${nonce}, gasLimit=${tx.gasLimit}`);
|
||||||
|
|
||||||
|
|
@ -213,8 +240,43 @@ export class Erc20TransferService {
|
||||||
const signatureHex = await this.mpcSigningClient.signMessage(unsignedTxHash);
|
const signatureHex = await this.mpcSigningClient.signMessage(unsignedTxHash);
|
||||||
this.logger.log(`[TRANSFER] MPC signature obtained: ${signatureHex.slice(0, 20)}...`);
|
this.logger.log(`[TRANSFER] MPC signature obtained: ${signatureHex.slice(0, 20)}...`);
|
||||||
|
|
||||||
// 解析签名
|
// 解析签名 - MPC 返回 64 字节 (r+s),需要转换为 ethers.js 格式
|
||||||
const signature = Signature.from(signatureHex);
|
// 确保有 0x 前缀
|
||||||
|
const normalizedSig = signatureHex.startsWith('0x') ? signatureHex : `0x${signatureHex}`;
|
||||||
|
this.logger.log(`[TRANSFER] Normalized signature: ${normalizedSig.slice(0, 22)}...`);
|
||||||
|
|
||||||
|
// MPC 签名是 64 字节 (r: 32 bytes + s: 32 bytes),需要添加 v (recovery id)
|
||||||
|
// 对于 EIP-1559 交易,v = 0 或 1 (yParity)
|
||||||
|
// 我们需要尝试两个值来恢复正确的地址
|
||||||
|
const sigBytes = normalizedSig.slice(2); // 去掉 0x
|
||||||
|
const r = `0x${sigBytes.slice(0, 64)}`;
|
||||||
|
const s = `0x${sigBytes.slice(64, 128)}`;
|
||||||
|
|
||||||
|
this.logger.log(`[TRANSFER] Signature r: ${r.slice(0, 20)}...`);
|
||||||
|
this.logger.log(`[TRANSFER] Signature s: ${s.slice(0, 20)}...`);
|
||||||
|
|
||||||
|
// 尝试 yParity 0 和 1 来找到正确的 recovery id
|
||||||
|
let signature: Signature | null = null;
|
||||||
|
for (const yParity of [0, 1] as const) {
|
||||||
|
try {
|
||||||
|
const testSig = Signature.from({ r, s, yParity });
|
||||||
|
// 使用 recoverAddress 验证签名
|
||||||
|
const recoveredAddress = recoverAddress(unsignedTxHash, testSig);
|
||||||
|
this.logger.log(`[TRANSFER] Recovered address with yParity=${yParity}: ${recoveredAddress}`);
|
||||||
|
|
||||||
|
if (recoveredAddress.toLowerCase() === this.hotWalletAddress.toLowerCase()) {
|
||||||
|
this.logger.log(`[TRANSFER] Found correct yParity: ${yParity}`);
|
||||||
|
signature = testSig;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug(`[TRANSFER] yParity=${yParity} failed: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
throw new Error('Failed to recover correct signature - address mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
// 创建已签名交易
|
// 创建已签名交易
|
||||||
const signedTx = tx.clone();
|
const signedTx = tx.clone();
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ model WalletAccount {
|
||||||
// 状态
|
// 状态
|
||||||
status String @default("ACTIVE") @map("status") @db.VarChar(20)
|
status String @default("ACTIVE") @map("status") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 乐观锁版本号
|
||||||
|
version Int @default(0) @map("version")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,23 +10,36 @@ import {
|
||||||
IWalletAccountRepository,
|
IWalletAccountRepository,
|
||||||
WALLET_ACCOUNT_REPOSITORY,
|
WALLET_ACCOUNT_REPOSITORY,
|
||||||
} from '@/domain/repositories';
|
} from '@/domain/repositories';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { WithdrawalOrder, WalletAccount } from '@/domain/aggregates';
|
||||||
|
import { WithdrawalStatus, Money, UserId } from '@/domain/value-objects';
|
||||||
|
import { OptimisticLockError } from '@/shared/exceptions/domain.exception';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Withdrawal Status Handler
|
* Withdrawal Status Handler
|
||||||
*
|
*
|
||||||
* Handles withdrawal status events from blockchain-service.
|
* Handles withdrawal status events from blockchain-service.
|
||||||
* Updates withdrawal order status and handles fund refunds on failure.
|
* Updates withdrawal order status and handles fund refunds on failure.
|
||||||
|
*
|
||||||
|
* IMPORTANT:
|
||||||
|
* - All operations use database transactions for atomicity.
|
||||||
|
* - Wallet balance updates use optimistic locking to prevent concurrent modification issues.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WithdrawalStatusHandler implements OnModuleInit {
|
export class WithdrawalStatusHandler implements OnModuleInit {
|
||||||
private readonly logger = new Logger(WithdrawalStatusHandler.name);
|
private readonly logger = new Logger(WithdrawalStatusHandler.name);
|
||||||
|
|
||||||
|
// Max retry count for optimistic lock conflicts
|
||||||
|
private readonly MAX_RETRIES = 3;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly withdrawalEventConsumer: WithdrawalEventConsumerService,
|
private readonly withdrawalEventConsumer: WithdrawalEventConsumerService,
|
||||||
@Inject(WITHDRAWAL_ORDER_REPOSITORY)
|
@Inject(WITHDRAWAL_ORDER_REPOSITORY)
|
||||||
private readonly withdrawalRepo: IWithdrawalOrderRepository,
|
private readonly withdrawalRepo: IWithdrawalOrderRepository,
|
||||||
@Inject(WALLET_ACCOUNT_REPOSITORY)
|
@Inject(WALLET_ACCOUNT_REPOSITORY)
|
||||||
private readonly walletRepo: IWalletAccountRepository,
|
private readonly walletRepo: IWalletAccountRepository,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
|
|
@ -41,7 +54,9 @@ export class WithdrawalStatusHandler implements OnModuleInit {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle withdrawal confirmed event
|
* Handle withdrawal confirmed event
|
||||||
* Update order status to CONFIRMED and store txHash
|
* Update order status to CONFIRMED, store txHash, and deduct frozen balance
|
||||||
|
*
|
||||||
|
* Uses database transaction + optimistic locking to ensure atomicity and prevent race conditions.
|
||||||
*/
|
*/
|
||||||
private async handleWithdrawalConfirmed(
|
private async handleWithdrawalConfirmed(
|
||||||
payload: WithdrawalConfirmedPayload,
|
payload: WithdrawalConfirmedPayload,
|
||||||
|
|
@ -50,26 +65,129 @@ export class WithdrawalStatusHandler implements OnModuleInit {
|
||||||
this.logger.log(`[CONFIRMED] orderNo: ${payload.orderNo}`);
|
this.logger.log(`[CONFIRMED] orderNo: ${payload.orderNo}`);
|
||||||
this.logger.log(`[CONFIRMED] txHash: ${payload.txHash}`);
|
this.logger.log(`[CONFIRMED] txHash: ${payload.txHash}`);
|
||||||
|
|
||||||
|
let retries = 0;
|
||||||
|
while (retries < this.MAX_RETRIES) {
|
||||||
|
try {
|
||||||
|
await this.executeWithdrawalConfirmed(payload);
|
||||||
|
return; // Success, exit
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isOptimisticLockError(error)) {
|
||||||
|
retries++;
|
||||||
|
this.logger.warn(`[CONFIRMED] Optimistic lock conflict for ${payload.orderNo}, retry ${retries}/${this.MAX_RETRIES}`);
|
||||||
|
if (retries >= this.MAX_RETRIES) {
|
||||||
|
this.logger.error(`[CONFIRMED] Max retries exceeded for ${payload.orderNo}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Brief delay before retry
|
||||||
|
await this.sleep(50 * retries);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the withdrawal confirmed logic within a transaction
|
||||||
|
*/
|
||||||
|
private async executeWithdrawalConfirmed(
|
||||||
|
payload: WithdrawalConfirmedPayload,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Find the withdrawal order
|
// Use transaction to ensure atomicity
|
||||||
const order = await this.withdrawalRepo.findByOrderNo(payload.orderNo);
|
await this.prisma.$transaction(async (tx) => {
|
||||||
if (!order) {
|
// Find the withdrawal order
|
||||||
this.logger.error(`[CONFIRMED] Order not found: ${payload.orderNo}`);
|
const orderRecord = await tx.withdrawalOrder.findUnique({
|
||||||
return;
|
where: { orderNo: payload.orderNo },
|
||||||
}
|
});
|
||||||
|
|
||||||
// Update order status: FROZEN -> BROADCASTED -> CONFIRMED
|
if (!orderRecord) {
|
||||||
// If still FROZEN, first mark as broadcasted with txHash
|
this.logger.error(`[CONFIRMED] Order not found: ${payload.orderNo}`);
|
||||||
if (order.isFrozen) {
|
return;
|
||||||
order.markAsBroadcasted(payload.txHash);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Then mark as confirmed
|
// Check if already confirmed (idempotency)
|
||||||
if (order.isBroadcasted) {
|
if (orderRecord.status === WithdrawalStatus.CONFIRMED) {
|
||||||
order.markAsConfirmed();
|
this.logger.log(`[CONFIRMED] Order ${payload.orderNo} already confirmed, skipping`);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.withdrawalRepo.save(order);
|
// Determine new status based on current status
|
||||||
|
let newStatus = orderRecord.status;
|
||||||
|
let txHash = orderRecord.txHash;
|
||||||
|
let broadcastedAt = orderRecord.broadcastedAt;
|
||||||
|
let confirmedAt = orderRecord.confirmedAt;
|
||||||
|
|
||||||
|
// FROZEN -> BROADCASTED -> CONFIRMED
|
||||||
|
if (orderRecord.status === WithdrawalStatus.FROZEN) {
|
||||||
|
newStatus = WithdrawalStatus.BROADCASTED;
|
||||||
|
txHash = payload.txHash;
|
||||||
|
broadcastedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStatus === WithdrawalStatus.BROADCASTED || orderRecord.status === WithdrawalStatus.BROADCASTED) {
|
||||||
|
newStatus = WithdrawalStatus.CONFIRMED;
|
||||||
|
confirmedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
await tx.withdrawalOrder.update({
|
||||||
|
where: { id: orderRecord.id },
|
||||||
|
data: {
|
||||||
|
status: newStatus,
|
||||||
|
txHash,
|
||||||
|
broadcastedAt,
|
||||||
|
confirmedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find wallet and deduct frozen balance with optimistic lock
|
||||||
|
let walletRecord = await tx.walletAccount.findUnique({
|
||||||
|
where: { accountSequence: orderRecord.accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!walletRecord) {
|
||||||
|
walletRecord = await tx.walletAccount.findUnique({
|
||||||
|
where: { userId: orderRecord.userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (walletRecord) {
|
||||||
|
// Deduct the total frozen amount (amount + fee)
|
||||||
|
const totalAmount = new Decimal(orderRecord.amount.toString()).add(new Decimal(orderRecord.fee.toString()));
|
||||||
|
const currentFrozen = new Decimal(walletRecord.usdtFrozen.toString());
|
||||||
|
|
||||||
|
if (currentFrozen.lessThan(totalAmount)) {
|
||||||
|
this.logger.error(`[CONFIRMED] Insufficient frozen balance: have ${currentFrozen}, need ${totalAmount}`);
|
||||||
|
throw new Error(`Insufficient frozen balance for withdrawal ${payload.orderNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFrozen = currentFrozen.minus(totalAmount);
|
||||||
|
const currentVersion = walletRecord.version;
|
||||||
|
|
||||||
|
// Optimistic lock: update only if version matches
|
||||||
|
const updateResult = await tx.walletAccount.updateMany({
|
||||||
|
where: {
|
||||||
|
id: walletRecord.id,
|
||||||
|
version: currentVersion, // Optimistic lock condition
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
usdtFrozen: newFrozen,
|
||||||
|
version: currentVersion + 1, // Increment version
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
// Version mismatch - another transaction modified the record
|
||||||
|
throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[CONFIRMED] Deducted ${totalAmount.toString()} USDT from frozen balance for ${orderRecord.accountSequence} (version: ${currentVersion} -> ${currentVersion + 1})`);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`[CONFIRMED] Wallet not found for accountSequence: ${orderRecord.accountSequence}, userId: ${orderRecord.userId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(`[CONFIRMED] Order ${payload.orderNo} confirmed successfully`);
|
this.logger.log(`[CONFIRMED] Order ${payload.orderNo} confirmed successfully`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -80,7 +198,9 @@ export class WithdrawalStatusHandler implements OnModuleInit {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle withdrawal failed event
|
* Handle withdrawal failed event
|
||||||
* Update order status to FAILED and refund frozen funds
|
* Update order status to FAILED and refund frozen funds (amount + fee)
|
||||||
|
*
|
||||||
|
* Uses database transaction + optimistic locking to ensure atomicity and prevent race conditions.
|
||||||
*/
|
*/
|
||||||
private async handleWithdrawalFailed(
|
private async handleWithdrawalFailed(
|
||||||
payload: WithdrawalFailedPayload,
|
payload: WithdrawalFailedPayload,
|
||||||
|
|
@ -89,35 +209,126 @@ export class WithdrawalStatusHandler implements OnModuleInit {
|
||||||
this.logger.log(`[FAILED] orderNo: ${payload.orderNo}`);
|
this.logger.log(`[FAILED] orderNo: ${payload.orderNo}`);
|
||||||
this.logger.log(`[FAILED] error: ${payload.error}`);
|
this.logger.log(`[FAILED] error: ${payload.error}`);
|
||||||
|
|
||||||
try {
|
let retries = 0;
|
||||||
// Find the withdrawal order
|
while (retries < this.MAX_RETRIES) {
|
||||||
const order = await this.withdrawalRepo.findByOrderNo(payload.orderNo);
|
try {
|
||||||
if (!order) {
|
await this.executeWithdrawalFailed(payload);
|
||||||
this.logger.error(`[FAILED] Order not found: ${payload.orderNo}`);
|
return; // Success, exit
|
||||||
return;
|
} catch (error) {
|
||||||
}
|
if (this.isOptimisticLockError(error)) {
|
||||||
|
retries++;
|
||||||
// Mark order as failed
|
this.logger.warn(`[FAILED] Optimistic lock conflict for ${payload.orderNo}, retry ${retries}/${this.MAX_RETRIES}`);
|
||||||
order.markAsFailed(payload.error);
|
if (retries >= this.MAX_RETRIES) {
|
||||||
await this.withdrawalRepo.save(order);
|
this.logger.error(`[FAILED] Max retries exceeded for ${payload.orderNo}`);
|
||||||
|
throw error;
|
||||||
// Refund frozen funds back to available balance if needed
|
}
|
||||||
if (order.needsUnfreeze()) {
|
// Brief delay before retry
|
||||||
// 优先使用 accountSequence 查找钱包(更可靠,避免 userId 变化导致扣错人)
|
await this.sleep(50 * retries);
|
||||||
let wallet = await this.walletRepo.findByAccountSequence(order.accountSequence);
|
|
||||||
if (!wallet) {
|
|
||||||
// 兜底:使用 userId 查找
|
|
||||||
wallet = await this.walletRepo.findByUserId(order.userId.value);
|
|
||||||
}
|
|
||||||
if (wallet) {
|
|
||||||
// Unfreeze the amount (add back to available balance)
|
|
||||||
wallet.unfreeze(order.amount);
|
|
||||||
await this.walletRepo.save(wallet);
|
|
||||||
this.logger.log(`[FAILED] Refunded ${order.amount.value} USDT to account ${order.accountSequence}`);
|
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(`[FAILED] Wallet not found for accountSequence: ${order.accountSequence}, userId: ${order.userId}`);
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the withdrawal failed logic within a transaction
|
||||||
|
*/
|
||||||
|
private async executeWithdrawalFailed(
|
||||||
|
payload: WithdrawalFailedPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Use transaction to ensure atomicity
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
// Find the withdrawal order
|
||||||
|
const orderRecord = await tx.withdrawalOrder.findUnique({
|
||||||
|
where: { orderNo: payload.orderNo },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!orderRecord) {
|
||||||
|
this.logger.error(`[FAILED] Order not found: ${payload.orderNo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already in terminal state (idempotency)
|
||||||
|
if (orderRecord.status === WithdrawalStatus.CONFIRMED ||
|
||||||
|
orderRecord.status === WithdrawalStatus.FAILED ||
|
||||||
|
orderRecord.status === WithdrawalStatus.CANCELLED) {
|
||||||
|
this.logger.log(`[FAILED] Order ${payload.orderNo} already in terminal state: ${orderRecord.status}, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if needs unfreeze (was frozen)
|
||||||
|
const needsUnfreeze = orderRecord.frozenAt !== null;
|
||||||
|
|
||||||
|
// Update order status to FAILED
|
||||||
|
await tx.withdrawalOrder.update({
|
||||||
|
where: { id: orderRecord.id },
|
||||||
|
data: {
|
||||||
|
status: WithdrawalStatus.FAILED,
|
||||||
|
errorMessage: payload.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refund frozen funds back to available balance if needed
|
||||||
|
if (needsUnfreeze) {
|
||||||
|
let walletRecord = await tx.walletAccount.findUnique({
|
||||||
|
where: { accountSequence: orderRecord.accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!walletRecord) {
|
||||||
|
walletRecord = await tx.walletAccount.findUnique({
|
||||||
|
where: { userId: orderRecord.userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (walletRecord) {
|
||||||
|
// Unfreeze the total amount (amount + fee)
|
||||||
|
const totalAmount = new Decimal(orderRecord.amount.toString()).add(new Decimal(orderRecord.fee.toString()));
|
||||||
|
const currentFrozen = new Decimal(walletRecord.usdtFrozen.toString());
|
||||||
|
const currentAvailable = new Decimal(walletRecord.usdtAvailable.toString());
|
||||||
|
const currentVersion = walletRecord.version;
|
||||||
|
|
||||||
|
// Validate frozen balance
|
||||||
|
let newFrozen: Decimal;
|
||||||
|
let newAvailable: Decimal;
|
||||||
|
|
||||||
|
if (currentFrozen.lessThan(totalAmount)) {
|
||||||
|
this.logger.warn(`[FAILED] Frozen balance (${currentFrozen}) less than refund amount (${totalAmount}), refunding what's available`);
|
||||||
|
// Refund whatever is frozen (shouldn't happen in normal flow)
|
||||||
|
const refundAmount = Decimal.min(currentFrozen, totalAmount);
|
||||||
|
newFrozen = currentFrozen.minus(refundAmount);
|
||||||
|
newAvailable = currentAvailable.add(refundAmount);
|
||||||
|
} else {
|
||||||
|
newFrozen = currentFrozen.minus(totalAmount);
|
||||||
|
newAvailable = currentAvailable.add(totalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic lock: update only if version matches
|
||||||
|
const updateResult = await tx.walletAccount.updateMany({
|
||||||
|
where: {
|
||||||
|
id: walletRecord.id,
|
||||||
|
version: currentVersion, // Optimistic lock condition
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
usdtFrozen: newFrozen,
|
||||||
|
usdtAvailable: newAvailable,
|
||||||
|
version: currentVersion + 1, // Increment version
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
// Version mismatch - another transaction modified the record
|
||||||
|
throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[FAILED] Refunded ${totalAmount.toString()} USDT (amount + fee) to account ${orderRecord.accountSequence} (version: ${currentVersion} -> ${currentVersion + 1})`);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`[FAILED] Wallet not found for accountSequence: ${orderRecord.accountSequence}, userId: ${orderRecord.userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(`[FAILED] Order ${payload.orderNo} marked as failed`);
|
this.logger.log(`[FAILED] Order ${payload.orderNo} marked as failed`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -125,4 +336,18 @@ export class WithdrawalStatusHandler implements OnModuleInit {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is an optimistic lock error
|
||||||
|
*/
|
||||||
|
private isOptimisticLockError(error: unknown): boolean {
|
||||||
|
return error instanceof OptimisticLockError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for specified milliseconds
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
FreezeForPlantingCommand, ConfirmPlantingDeductionCommand, UnfreezeForPlantingCommand,
|
FreezeForPlantingCommand, ConfirmPlantingDeductionCommand, UnfreezeForPlantingCommand,
|
||||||
} from '@/application/commands';
|
} from '@/application/commands';
|
||||||
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
|
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
|
||||||
import { DuplicateTransactionError, WalletNotFoundError } from '@/shared/exceptions/domain.exception';
|
import { DuplicateTransactionError, WalletNotFoundError, OptimisticLockError } from '@/shared/exceptions/domain.exception';
|
||||||
import { WalletCacheService } from '@/infrastructure/redis';
|
import { WalletCacheService } from '@/infrastructure/redis';
|
||||||
import { EventPublisherService } from '@/infrastructure/kafka';
|
import { EventPublisherService } from '@/infrastructure/kafka';
|
||||||
import { WithdrawalRequestedEvent } from '@/domain/events';
|
import { WithdrawalRequestedEvent } from '@/domain/events';
|
||||||
|
|
@ -93,53 +93,165 @@ export class WalletApplicationService {
|
||||||
|
|
||||||
// =============== Commands ===============
|
// =============== Commands ===============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle deposit with transaction protection and optimistic locking
|
||||||
|
*
|
||||||
|
* Uses database transaction to ensure atomicity of:
|
||||||
|
* 1. Deposit order creation
|
||||||
|
* 2. Wallet balance update (with optimistic lock)
|
||||||
|
* 3. Ledger entry creation
|
||||||
|
*/
|
||||||
async handleDeposit(command: HandleDepositCommand): Promise<void> {
|
async handleDeposit(command: HandleDepositCommand): Promise<void> {
|
||||||
// Check for duplicate transaction
|
const MAX_RETRIES = 3;
|
||||||
const exists = await this.depositRepo.existsByTxHash(command.txHash);
|
let retries = 0;
|
||||||
if (exists) {
|
|
||||||
throw new DuplicateTransactionError(command.txHash);
|
while (retries < MAX_RETRIES) {
|
||||||
|
try {
|
||||||
|
await this.executeHandleDeposit(command);
|
||||||
|
return; // Success, exit
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isOptimisticLockError(error)) {
|
||||||
|
retries++;
|
||||||
|
this.logger.warn(`[DEPOSIT] Optimistic lock conflict for ${command.txHash}, retry ${retries}/${MAX_RETRIES}`);
|
||||||
|
if (retries >= MAX_RETRIES) {
|
||||||
|
this.logger.error(`[DEPOSIT] Max retries exceeded for ${command.txHash}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Brief delay before retry
|
||||||
|
await this.sleep(50 * retries);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute deposit logic within a transaction
|
||||||
|
*/
|
||||||
|
private async executeHandleDeposit(command: HandleDepositCommand): Promise<void> {
|
||||||
const accountSequence = command.accountSequence;
|
const accountSequence = command.accountSequence;
|
||||||
const userId = BigInt(command.userId);
|
const userIdBigint = BigInt(command.userId);
|
||||||
const amount = Money.USDT(command.amount);
|
const amountDecimal = new (await import('decimal.js')).default(command.amount);
|
||||||
|
|
||||||
// Get or create wallet by accountSequence (跨服务关联标识)
|
await this.prisma.$transaction(async (tx) => {
|
||||||
const wallet = await this.walletRepo.getOrCreate(accountSequence, userId);
|
// Check for duplicate transaction within transaction
|
||||||
|
const existingDeposit = await tx.depositOrder.findUnique({
|
||||||
|
where: { txHash: command.txHash },
|
||||||
|
});
|
||||||
|
if (existingDeposit) {
|
||||||
|
throw new DuplicateTransactionError(command.txHash);
|
||||||
|
}
|
||||||
|
|
||||||
// Create deposit order
|
// Get or create wallet
|
||||||
const depositOrder = DepositOrder.create({
|
let walletRecord = await tx.walletAccount.findUnique({
|
||||||
accountSequence,
|
where: { accountSequence },
|
||||||
userId: UserId.create(userId),
|
});
|
||||||
chainType: command.chainType,
|
|
||||||
amount,
|
if (!walletRecord) {
|
||||||
txHash: command.txHash,
|
walletRecord = await tx.walletAccount.create({
|
||||||
|
data: {
|
||||||
|
accountSequence,
|
||||||
|
userId: userIdBigint,
|
||||||
|
usdtAvailable: 0,
|
||||||
|
usdtFrozen: 0,
|
||||||
|
dstAvailable: 0,
|
||||||
|
dstFrozen: 0,
|
||||||
|
bnbAvailable: 0,
|
||||||
|
bnbFrozen: 0,
|
||||||
|
ogAvailable: 0,
|
||||||
|
ogFrozen: 0,
|
||||||
|
rwadAvailable: 0,
|
||||||
|
rwadFrozen: 0,
|
||||||
|
hashpower: 0,
|
||||||
|
pendingUsdt: 0,
|
||||||
|
pendingHashpower: 0,
|
||||||
|
settleableUsdt: 0,
|
||||||
|
settleableHashpower: 0,
|
||||||
|
settledTotalUsdt: 0,
|
||||||
|
settledTotalHashpower: 0,
|
||||||
|
expiredTotalUsdt: 0,
|
||||||
|
expiredTotalHashpower: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
version: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create deposit order
|
||||||
|
await tx.depositOrder.create({
|
||||||
|
data: {
|
||||||
|
accountSequence,
|
||||||
|
userId: userIdBigint,
|
||||||
|
chainType: command.chainType,
|
||||||
|
amount: amountDecimal,
|
||||||
|
txHash: command.txHash,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
confirmedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update wallet balance with optimistic lock
|
||||||
|
const Decimal = (await import('decimal.js')).default;
|
||||||
|
const currentAvailable = new Decimal(walletRecord.usdtAvailable.toString());
|
||||||
|
const newAvailable = currentAvailable.add(amountDecimal);
|
||||||
|
const currentVersion = walletRecord.version;
|
||||||
|
|
||||||
|
const updateResult = await tx.walletAccount.updateMany({
|
||||||
|
where: {
|
||||||
|
id: walletRecord.id,
|
||||||
|
version: currentVersion, // Optimistic lock condition
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
usdtAvailable: newAvailable,
|
||||||
|
version: currentVersion + 1, // Increment version
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
// Version mismatch - another transaction modified the record
|
||||||
|
throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record ledger entry
|
||||||
|
const entryType = command.chainType === ChainType.KAVA
|
||||||
|
? LedgerEntryType.DEPOSIT_KAVA
|
||||||
|
: LedgerEntryType.DEPOSIT_BSC;
|
||||||
|
|
||||||
|
await tx.ledgerEntry.create({
|
||||||
|
data: {
|
||||||
|
accountSequence,
|
||||||
|
userId: userIdBigint,
|
||||||
|
entryType,
|
||||||
|
amount: amountDecimal,
|
||||||
|
assetType: 'USDT',
|
||||||
|
balanceAfter: newAvailable,
|
||||||
|
refTxHash: command.txHash,
|
||||||
|
memo: `Deposit from ${command.chainType}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[DEPOSIT] Credited ${amountDecimal.toString()} USDT to ${accountSequence} (version: ${currentVersion} -> ${currentVersion + 1})`);
|
||||||
});
|
});
|
||||||
depositOrder.confirm();
|
|
||||||
await this.depositRepo.save(depositOrder);
|
|
||||||
|
|
||||||
// Credit wallet
|
// Invalidate wallet cache after deposit (outside transaction)
|
||||||
wallet.deposit(amount, command.chainType, command.txHash);
|
await this.walletCacheService.invalidateWallet(userIdBigint);
|
||||||
await this.walletRepo.save(wallet);
|
}
|
||||||
|
|
||||||
// Record ledger entry (append-only, 可审计)
|
/**
|
||||||
const entryType = command.chainType === ChainType.KAVA
|
* Check if error is an optimistic lock error
|
||||||
? LedgerEntryType.DEPOSIT_KAVA
|
*/
|
||||||
: LedgerEntryType.DEPOSIT_BSC;
|
private isOptimisticLockError(error: unknown): boolean {
|
||||||
|
return error instanceof OptimisticLockError ||
|
||||||
|
(error instanceof Error && error.message.includes('Optimistic lock conflict'));
|
||||||
|
}
|
||||||
|
|
||||||
const ledgerEntry = LedgerEntry.create({
|
/**
|
||||||
accountSequence,
|
* Sleep for specified milliseconds
|
||||||
userId: UserId.create(userId),
|
*/
|
||||||
entryType,
|
private sleep(ms: number): Promise<void> {
|
||||||
amount,
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
balanceAfter: wallet.balances.usdt.available,
|
|
||||||
refTxHash: command.txHash,
|
|
||||||
memo: `Deposit from ${command.chainType}`,
|
|
||||||
});
|
|
||||||
await this.ledgerRepo.save(ledgerEntry);
|
|
||||||
|
|
||||||
// Invalidate wallet cache after deposit
|
|
||||||
await this.walletCacheService.invalidateWallet(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deductForPlanting(command: DeductForPlantingCommand): Promise<boolean> {
|
async deductForPlanting(command: DeductForPlantingCommand): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,9 @@ export class DepositEventConsumerService implements OnModuleInit, OnModuleDestro
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Error processing deposit event from ${topic}`, error);
|
this.logger.error(`[ERROR] Error processing deposit event from ${topic}`, error);
|
||||||
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
|
// This ensures messages are not marked as consumed until successfully processed
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,9 @@ export class WithdrawalEventConsumerService implements OnModuleInit, OnModuleDes
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[ERROR] Error processing withdrawal event from ${topic}`, error);
|
this.logger.error(`[ERROR] Error processing withdrawal event from ${topic}`, error);
|
||||||
|
// Re-throw to trigger Kafka retry mechanism
|
||||||
|
// This ensures messages are not marked as consumed until successfully processed
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,10 @@ export class InvalidOperationError extends DomainError {
|
||||||
this.name = 'InvalidOperationError';
|
this.name = 'InvalidOperationError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OptimisticLockError extends DomainError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'OptimisticLockError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue