Skip to content

Commit 9c2081d

Browse files
authored
Merge pull request #3207 from DFXswiss/develop
Release: develop -> main
2 parents 9bf7fda + 1fec251 commit 9c2081d

10 files changed

Lines changed: 153 additions & 50 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
3+
* @typedef {import('typeorm').QueryRunner} QueryRunner
4+
*/
5+
6+
/**
7+
* @class
8+
* @implements {MigrationInterface}
9+
*/
10+
module.exports = class AddCountryManualReviewRequired1771340546052 {
11+
name = 'AddCountryManualReviewRequired1771340546052'
12+
13+
/**
14+
* @param {QueryRunner} queryRunner
15+
*/
16+
async up(queryRunner) {
17+
await queryRunner.query(`ALTER TABLE "country" ADD "manualReviewRequired" bit NOT NULL CONSTRAINT "DF_e1a3302c583e7c2e4afcbe524f9" DEFAULT 0`);
18+
await queryRunner.query(`ALTER TABLE "country" ADD "manualReviewRequiredOrganization" bit NOT NULL CONSTRAINT "DF_4852972602f8b5412de725340f9" DEFAULT 0`);
19+
}
20+
21+
/**
22+
* @param {QueryRunner} queryRunner
23+
*/
24+
async down(queryRunner) {
25+
await queryRunner.query(`ALTER TABLE "country" DROP CONSTRAINT "DF_4852972602f8b5412de725340f9"`);
26+
await queryRunner.query(`ALTER TABLE "country" DROP COLUMN "manualReviewRequiredOrganization"`);
27+
await queryRunner.query(`ALTER TABLE "country" DROP CONSTRAINT "DF_e1a3302c583e7c2e4afcbe524f9"`);
28+
await queryRunner.query(`ALTER TABLE "country" DROP COLUMN "manualReviewRequired"`);
29+
}
30+
}

src/shared/models/country/country.entity.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ export class Country extends IEntity {
5757
@Column({ default: AmlRule.DEFAULT })
5858
amlRule: AmlRule;
5959

60+
@Column({ default: false })
61+
manualReviewRequired: boolean;
62+
63+
@Column({ default: false })
64+
manualReviewRequiredOrganization: boolean;
65+
6066
@Column({ length: 'MAX', nullable: true })
6167
enabledKycDocuments: string; // semicolon separated KycDocuments
6268

src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.e
2020
import { isAsset } from 'src/shared/models/active';
2121
import { Asset, AssetType } from 'src/shared/models/asset/asset.entity';
2222
import { AssetService } from 'src/shared/models/asset/asset.service';
23+
import { SettingService } from 'src/shared/models/setting/setting.service';
2324
import { DfxLogger } from 'src/shared/services/dfx-logger';
2425
import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity';
2526
import { LiquidityManagementSystem } from '../../enums';
@@ -34,9 +35,9 @@ export enum ClementineBridgeCommands {
3435
}
3536

3637
/**
37-
* Correlation ID format for tracking operations:
38-
* - Deposit: clementine:deposit:{depositAddress}:{btcTxId}
39-
* - Withdraw: clementine:withdraw:{step}:{signerAddress}:{destinationAddress}:{withdrawalUtxo}:{optimisticSig}:{operatorSig}
38+
* Correlation ID format for tracking operations (base64-encoded JSON after prefix):
39+
* - Deposit: clementine:deposit:{base64(DepositCorrelationData)}
40+
* - Withdraw: clementine:withdraw:{base64(WithdrawCorrelationData)}
4041
*
4142
* Withdrawal steps: dust_sent, scanning, signatures_generated, sent_to_bridge, waiting_optimistic, sent_to_operators
4243
*/
@@ -72,6 +73,12 @@ const BITCOIN_RELAY_CONFIRMATIONS: Record<ClementineNetwork, number> = {
7273
[ClementineNetwork.TESTNET4]: 100,
7374
};
7475

76+
interface DepositCorrelationData {
77+
depositAddress: string;
78+
citreaAddress: string;
79+
btcTxId?: string;
80+
}
81+
7582
interface WithdrawCorrelationData {
7683
step: string;
7784
signerAddress: string;
@@ -107,6 +114,7 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter {
107114
private readonly assetService: AssetService,
108115
private readonly bitcoinFeeService: BitcoinFeeService,
109116
private readonly bitcoinTestnet4FeeService: BitcoinTestnet4FeeService,
117+
private readonly settingService: SettingService,
110118
) {
111119
super(LiquidityManagementSystem.CLEMENTINE_BRIDGE);
112120

@@ -152,6 +160,10 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter {
152160
/**
153161
* Deposit BTC to receive cBTC on Citrea
154162
* Note: Clementine uses a fixed bridge amount of 10 BTC
163+
*
164+
* Deposit is a two-step process:
165+
* 1. Generate deposit address (returns pending_confirmation)
166+
* 2. After manual approval, send BTC to deposit address
155167
*/
156168
private async deposit(order: LiquidityManagementOrder): Promise<CorrelationId> {
157169
const {
@@ -189,19 +201,21 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter {
189201
const { depositAddress } = await this.clementineClient.depositStart(this.recoveryTaprootAddress, citreaAddress);
190202

191203
this.logger.info(
192-
`Deposit address generated: ${depositAddress}, recovery: ${this.recoveryTaprootAddress}, citrea: ${citreaAddress}`,
204+
`Deposit address generated: ${depositAddress}, recovery: ${this.recoveryTaprootAddress}, citrea: ${citreaAddress}. Waiting for manual approval before sending BTC.`,
193205
);
194206

195207
// Update order with fixed amount
196208
order.inputAmount = CLEMENTINE_BRIDGE_AMOUNT_BTC;
197209
order.inputAsset = bitcoinAsset.name;
198210
order.outputAsset = citreaAsset.name;
199211

200-
// Send BTC to the deposit address
201-
const btcTxId = await this.sendBtcToAddress(depositAddress, CLEMENTINE_BRIDGE_AMOUNT_BTC);
212+
// Store deposit data - BTC will be sent after manual approval
213+
const correlationData: DepositCorrelationData = {
214+
depositAddress,
215+
citreaAddress,
216+
};
202217

203-
// Store deposit address and txId in correlation ID for status checks
204-
return `${CORRELATION_PREFIX.DEPOSIT}${depositAddress}:${btcTxId}`;
218+
return `${CORRELATION_PREFIX.DEPOSIT}${this.encodeDepositCorrelation(correlationData)}`;
205219
}
206220

207221
/**
@@ -291,21 +305,44 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter {
291305
}
292306

293307
try {
294-
// Extract deposit address and BTC txId from correlation ID
295-
const correlationData = order.correlationId.replace(CORRELATION_PREFIX.DEPOSIT, '');
296-
const [depositAddress, btcTxId] = correlationData.split(':');
308+
const correlationData = this.decodeDepositCorrelation(
309+
order.correlationId.replace(CORRELATION_PREFIX.DEPOSIT, ''),
310+
);
297311

298-
// Step 1: Verify the Bitcoin transaction has enough confirmations for block relay
299-
if (btcTxId && !(await this.isBtcTxRelayConfirmed(btcTxId))) {
312+
this.logger.verbose(
313+
`Deposit check: address=${correlationData.depositAddress}, btcTxId=${correlationData.btcTxId ?? 'pending'}`,
314+
);
315+
316+
// Step 1: If no BTC sent yet, wait for manual approval via Setting
317+
if (!correlationData.btcTxId) {
318+
const isConfirmed = await this.isDepositApproved(correlationData.depositAddress);
319+
if (!isConfirmed) {
320+
this.logger.verbose(
321+
`Deposit ${correlationData.depositAddress}: waiting for manual approval (add to 'clementineApprovedDeposits' setting)`,
322+
);
323+
return false;
324+
}
325+
326+
await this.removeDepositApproval(correlationData.depositAddress);
327+
328+
const btcTxId = await this.sendBtcToAddress(correlationData.depositAddress, CLEMENTINE_BRIDGE_AMOUNT_BTC);
329+
correlationData.btcTxId = btcTxId;
330+
order.correlationId = `${CORRELATION_PREFIX.DEPOSIT}${this.encodeDepositCorrelation(correlationData)}`;
331+
this.logger.info(`Deposit ${correlationData.depositAddress}: approved and BTC sent, txId=${btcTxId}`);
332+
return false;
333+
}
334+
335+
// Step 2: Verify the Bitcoin transaction has enough confirmations for block relay
336+
if (correlationData.btcTxId && !(await this.isBtcTxRelayConfirmed(correlationData.btcTxId))) {
300337
this.logger.verbose(
301-
`Deposit ${depositAddress}: BTC TX not yet confirmed (need ${BITCOIN_RELAY_CONFIRMATIONS[this.network]}+)`,
338+
`Deposit ${correlationData.depositAddress}: BTC TX not yet confirmed (need ${BITCOIN_RELAY_CONFIRMATIONS[this.network]}+)`,
302339
);
303340
return false;
304341
}
305342

306-
// Step 2: Check Clementine deposit status
307-
const depositStatus = await this.clementineClient.depositStatus(depositAddress);
308-
this.logger.verbose(`Deposit ${depositAddress}: Clementine status = ${depositStatus.status}`);
343+
// Step 3: Check Clementine deposit status
344+
const depositStatus = await this.clementineClient.depositStatus(correlationData.depositAddress);
345+
this.logger.verbose(`Deposit ${correlationData.depositAddress}: Clementine status = ${depositStatus.status}`);
309346

310347
if (depositStatus.status === 'failed') {
311348
throw new OrderFailedException(`Clementine deposit failed: ${depositStatus.errorMessage ?? 'Unknown error'}`);
@@ -322,6 +359,17 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter {
322359
}
323360
}
324361

362+
private async isDepositApproved(depositAddress: string): Promise<boolean> {
363+
const approvedDeposits = await this.settingService.getObj<string[]>('clementineApprovedDeposits', []);
364+
return approvedDeposits.includes(depositAddress);
365+
}
366+
367+
private async removeDepositApproval(depositAddress: string): Promise<void> {
368+
const approvedDeposits = await this.settingService.getObj<string[]>('clementineApprovedDeposits', []);
369+
const updated = approvedDeposits.filter((addr) => addr !== depositAddress);
370+
await this.settingService.setObj('clementineApprovedDeposits', updated);
371+
}
372+
325373
private async checkWithdrawCompletion(order: LiquidityManagementOrder): Promise<boolean> {
326374
const {
327375
pipeline: {
@@ -589,6 +637,14 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter {
589637
return true;
590638
}
591639

640+
private encodeDepositCorrelation(data: DepositCorrelationData): string {
641+
return Buffer.from(JSON.stringify(data)).toString('base64');
642+
}
643+
644+
private decodeDepositCorrelation(encoded: string): DepositCorrelationData {
645+
return JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8'));
646+
}
647+
592648
private encodeWithdrawCorrelation(data: WithdrawCorrelationData): string {
593649
return Buffer.from(JSON.stringify(data)).toString('base64');
594650
}

src/subdomains/generic/kyc/dto/kyc-error.enum.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export enum KycError {
2929
IP_COUNTRY_MISMATCH = 'IpCountryMismatch',
3030
COUNTRY_IP_COUNTRY_MISMATCH = 'CountryIpCountryMismatch',
3131
RESIDENCE_PERMIT_CHECK_REQUIRED = 'ResidencePermitCheckRequired',
32+
MANUAL_REVIEW_REQUIRED = 'ManualReviewRequired',
3233

3334
// Recommendation errors
3435
EXPIRED_RECOMMENDATION = 'ExpiredRecommendation',
@@ -91,6 +92,7 @@ export const KycErrorMap: Record<KycError, string> = {
9192
[KycError.INCORRECT_INFO]: 'Incorrect response',
9293
[KycError.RESIDENCE_PERMIT_CHECK_REQUIRED]: undefined,
9394
[KycError.EXPIRED_STEP]: 'Your documents are expired',
95+
[KycError.MANUAL_REVIEW_REQUIRED]: undefined,
9496
};
9597

9698
export const KycReasonMap: { [e in KycError]?: KycStepReason } = {

src/subdomains/generic/kyc/services/kyc.service.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export class KycService {
207207
status: ReviewStatus.INTERNAL_REVIEW,
208208
userData: { kycSteps: { name: KycStepName.NATIONALITY_DATA, status: ReviewStatus.COMPLETED } },
209209
},
210-
relations: { userData: { users: true, wallet: true, kycFiles: true } },
210+
relations: { userData: { users: true, wallet: true, kycFiles: true, organization: { country: true } } },
211211
});
212212

213213
for (const entity of entities) {
@@ -1470,10 +1470,15 @@ export class KycService {
14701470

14711471
// Country & verifiedName check
14721472
const userCountry =
1473-
identStep.userData.organizationCountry ?? identStep.userData.verifiedCountry ?? identStep.userData.country;
1473+
identStep.userData.organizationCountry ??
1474+
identStep.userData.organization.country ??
1475+
identStep.userData.verifiedCountry ??
1476+
identStep.userData.country;
1477+
14741478
if (identStep.userData.accountType === AccountType.PERSONAL) {
14751479
// Personal Account
1476-
if (userCountry && !userCountry.dfxEnable) errors.push(KycError.COUNTRY_NOT_ALLOWED);
1480+
if (!userCountry.dfxEnable) errors.push(KycError.COUNTRY_NOT_ALLOWED);
1481+
if (userCountry.manualReviewRequired) errors.push(KycError.MANUAL_REVIEW_REQUIRED);
14771482

14781483
if (!identStep.userData.verifiedName && identStep.userData.status === UserDataStatus.ACTIVE) {
14791484
errors.push(KycError.VERIFIED_NAME_MISSING);
@@ -1485,7 +1490,8 @@ export class KycService {
14851490
}
14861491
} else {
14871492
// Business Account
1488-
if (userCountry && !userCountry.dfxOrganizationEnable) errors.push(KycError.COUNTRY_NOT_ALLOWED);
1493+
if (!userCountry.dfxOrganizationEnable) errors.push(KycError.COUNTRY_NOT_ALLOWED);
1494+
if (userCountry.manualReviewRequiredOrganization) errors.push(KycError.MANUAL_REVIEW_REQUIRED);
14891495
}
14901496

14911497
return errors;

src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@ import { RegisterStrategy } from './base/register.strategy';
2020
export class CardanoStrategy extends RegisterStrategy {
2121
protected logger: DfxLogger = new DfxLogger(CardanoStrategy);
2222

23+
private readonly cardanoPaymentDepositAddress: string;
24+
2325
constructor(
2426
private readonly payInCardanoService: PayInCardanoService,
2527
private readonly transactionRequestService: TransactionRequestService,
2628
) {
2729
super();
30+
31+
this.cardanoPaymentDepositAddress = CardanoUtil.createWallet({
32+
seed: Config.payment.cardanoSeed,
33+
index: 0,
34+
}).address;
2835
}
2936

3037
get blockchain(): Blockchain {
@@ -39,6 +46,8 @@ export class CardanoStrategy extends RegisterStrategy {
3946
this.blockchain,
4047
);
4148

49+
if (this.cardanoPaymentDepositAddress) activeDepositAddresses.push(this.cardanoPaymentDepositAddress);
50+
4251
await this.processNewPayInEntries(activeDepositAddresses.map((a) => BlockchainAddress.create(a, this.blockchain)));
4352
}
4453

@@ -126,8 +135,7 @@ export class CardanoStrategy extends RegisterStrategy {
126135
return payInEntries;
127136
}
128137

129-
private getTxType(depositAddress: string): PayInType {
130-
const paymentAddress = CardanoUtil.createWallet({ seed: Config.payment.cardanoSeed, index: 0 }).address;
131-
return Util.equalsIgnoreCase(paymentAddress, depositAddress) ? PayInType.PAYMENT : PayInType.DEPOSIT;
138+
private getTxType(address: string): PayInType {
139+
return Util.equalsIgnoreCase(this.cardanoPaymentDepositAddress, address) ? PayInType.PAYMENT : PayInType.DEPOSIT;
132140
}
133141
}

src/subdomains/supporting/realunit/controllers/realunit.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ export class RealUnitController {
362362
@GetJwt() jwt: JwtPayload,
363363
@Body() dto: RealUnitEmailRegistrationDto,
364364
): Promise<RealUnitEmailRegistrationResponseDto> {
365-
const status = await this.realunitService.registerEmail(jwt.account, jwt.address, dto);
365+
const status = await this.realunitService.registerEmail(jwt.account, dto);
366366
return { status };
367367
}
368368

src/subdomains/supporting/realunit/dto/realunit-dto.mapper.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,15 @@ export class RealUnitDtoMapper {
3232

3333
const historicalBalancesFilled = TimeseriesUtils.fillMissingDates(historicalBalances);
3434

35-
dto.historicalBalances = historicalBalancesFilled.map((hb) => ({
36-
balance: hb.balance,
37-
timestamp: hb.created,
38-
valueChf: Util.round(historicalPricesMap.get(Util.isoDate(hb.created))?.chf * Number(hb.balance), 4),
39-
}));
35+
dto.historicalBalances = historicalBalancesFilled.map((hb) => {
36+
const price = historicalPricesMap.get(Util.isoDate(hb.created));
37+
return {
38+
balance: hb.balance,
39+
timestamp: hb.created,
40+
valueChf: Util.round(price?.chf * Number(hb.balance), 4),
41+
valueEur: Util.round(price?.eur * Number(hb.balance), 4),
42+
};
43+
});
4044

4145
return dto;
4246
}

src/subdomains/supporting/realunit/dto/realunit.dto.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export class HistoricalBalanceDto {
1515

1616
@ApiPropertyOptional({ description: 'Valuation in CHF at this point in time' })
1717
valueChf?: number;
18+
19+
@ApiPropertyOptional({ description: 'Valuation in EUR at this point in time' })
20+
valueEur?: number;
1821
}
1922

2023
export class PageInfoDto implements PageInfo {

0 commit comments

Comments
 (0)