Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions migration/1770600000000-AddAktionariatResponse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddAktionariatResponse1770600000000 {
name = 'AddAktionariatResponse1770600000000';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "dbo"."transaction_request" ADD "aktionariatResponse" nvarchar(MAX)`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "dbo"."transaction_request" DROP COLUMN "aktionariatResponse"`);
}
};
20 changes: 13 additions & 7 deletions src/integration/blockchain/realunit/dto/realunit-broker.dto.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';

export class BrokerbotPriceDto {
@ApiProperty({ description: 'Current price per share in CHF (18 decimals formatted)' })
@ApiProperty({ description: 'Current price per share in CHF' })
pricePerShare: string;

@ApiProperty({ description: 'Raw price per share in wei' })
pricePerShareRaw: string;
@ApiProperty({ description: 'Available shares for purchase' })
availableShares: number;
}

export class BrokerbotBuyPriceDto {
@ApiProperty({ description: 'Number of shares' })
shares: number;

@ApiProperty({ description: 'Total cost in CHF (18 decimals formatted)' })
@ApiProperty({ description: 'Total cost in CHF' })
totalPrice: string;

@ApiProperty({ description: 'Raw total cost in wei' })
totalPriceRaw: string;

@ApiProperty({ description: 'Price per share in CHF' })
pricePerShare: string;

@ApiProperty({ description: 'Available shares for purchase' })
availableShares: number;
}

export class BrokerbotSharesDto {
Expand All @@ -31,6 +31,9 @@ export class BrokerbotSharesDto {

@ApiProperty({ description: 'Price per share in CHF' })
pricePerShare: string;

@ApiProperty({ description: 'Available shares for purchase' })
availableShares: number;
}

export class BrokerbotInfoDto {
Expand All @@ -51,4 +54,7 @@ export class BrokerbotInfoDto {

@ApiProperty({ description: 'Whether selling is enabled' })
sellingEnabled: boolean;

@ApiProperty({ description: 'Available shares for purchase' })
availableShares: number;
}
124 changes: 75 additions & 49 deletions src/integration/blockchain/realunit/realunit-blockchain.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Contract } from 'ethers';
import { Blockchain } from '../shared/enums/blockchain.enum';
import { EvmClient } from '../shared/evm/evm-client';
import { EvmUtil } from '../shared/evm/evm.util';
import { BlockchainRegistryService } from '../shared/services/blockchain-registry.service';
import { Injectable } from '@nestjs/common';
import { GetConfig } from 'src/config/config';
import { HttpService } from 'src/shared/services/http.service';
import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache';
import {
BrokerbotBuyPriceDto,
BrokerbotInfoDto,
Expand All @@ -17,86 +14,115 @@ const BROKERBOT_ADDRESS = '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d';
const REALU_TOKEN_ADDRESS = '0x553C7f9C780316FC1D34b8e14ac2465Ab22a090B';
const ZCHF_ADDRESS = '0xb58e61c3098d85632df34eecfb899a1ed80921cb';

// Contract ABIs
const BROKERBOT_ABI = [
'function getPrice() public view returns (uint256)',
'function getBuyPrice(uint256 shares) public view returns (uint256)',
'function getShares(uint256 money) public view returns (uint256)',
'function settings() public view returns (uint256)',
];
interface AktionariatPriceResponse {
priceInCHF: number;
priceInEUR: number;
availableShares: number;
}

@Injectable()
export class RealUnitBlockchainService implements OnModuleInit {
private registryService: BlockchainRegistryService;
interface PaymentInstructionsRequest {
currency: string;
address: string;
shares: number;
price: number;
}

constructor(private readonly moduleRef: ModuleRef) {}
interface PaymentInstructionsResponse {
[key: string]: unknown;
}

interface PayAndAllocateRequest {
amount: number;
ref: string;
}

private getEvmClient(): EvmClient {
return this.registryService.getClient(Blockchain.ETHEREUM) as EvmClient;
@Injectable()
export class RealUnitBlockchainService {
private readonly priceCache = new AsyncCache<AktionariatPriceResponse>(CacheItemResetPeriod.EVERY_30_SECONDS);

constructor(private readonly http: HttpService) {}

private async fetchPrice(): Promise<AktionariatPriceResponse> {
return this.priceCache.get(
'price',
async () => {
const { url, key } = GetConfig().blockchain.realunit.api;
return this.http.post<AktionariatPriceResponse>(`${url}/directinvestment/getPrice`, null, {
headers: { 'x-api-key': key },
});
},
undefined,
true,
);
}

async getRealUnitPriceChf(): Promise<number> {
return this.fetchPrice().then((r) => r.priceInCHF);
}

private getBrokerbotContract(): Contract {
return new Contract(BROKERBOT_ADDRESS, BROKERBOT_ABI, this.getEvmClient().wallet);
async getRealUnitPriceEur(): Promise<number> {
return this.fetchPrice().then((r) => r.priceInEUR);
}

onModuleInit() {
this.registryService = this.moduleRef.get(BlockchainRegistryService, { strict: false });
async requestPaymentInstructions(request: PaymentInstructionsRequest): Promise<PaymentInstructionsResponse> {
const { url, key } = GetConfig().blockchain.realunit.api;
return this.http.post(`${url}/directinvestment/requestPaymentInstructions`, request, {
headers: { 'x-api-key': key },
});
}

async getRealUnitPrice(): Promise<number> {
const price = await this.getBrokerbotContract().getPrice();
return EvmUtil.fromWeiAmount(price);
async payAndAllocate(request: PayAndAllocateRequest): Promise<void> {
const { url, key } = GetConfig().blockchain.realunit.api;
await this.http.post(`${url}/directinvestment/payAndAllocate`, request, {
headers: { 'x-api-key': key },
});
}

// --- Brokerbot Methods ---

async getBrokerbotPrice(): Promise<BrokerbotPriceDto> {
const priceRaw = await this.getBrokerbotContract().getPrice();
const { priceInCHF, availableShares } = await this.fetchPrice();
return {
pricePerShare: EvmUtil.fromWeiAmount(priceRaw).toString(),
pricePerShareRaw: priceRaw.toString(),
pricePerShare: priceInCHF.toString(),
availableShares,
};
}

async getBrokerbotBuyPrice(shares: number): Promise<BrokerbotBuyPriceDto> {
const contract = this.getBrokerbotContract();
const [totalPriceRaw, pricePerShareRaw] = await Promise.all([contract.getBuyPrice(shares), contract.getPrice()]);
const { priceInCHF, availableShares } = await this.fetchPrice();
const totalPrice = priceInCHF * shares;

return {
shares,
totalPrice: EvmUtil.fromWeiAmount(totalPriceRaw).toString(),
totalPriceRaw: totalPriceRaw.toString(),
pricePerShare: EvmUtil.fromWeiAmount(pricePerShareRaw).toString(),
totalPrice: totalPrice.toString(),
pricePerShare: priceInCHF.toString(),
availableShares,
};
}

async getBrokerbotShares(amountChf: string): Promise<BrokerbotSharesDto> {
const contract = this.getBrokerbotContract();
const amountWei = EvmUtil.toWeiAmount(parseFloat(amountChf));
const [shares, pricePerShareRaw] = await Promise.all([contract.getShares(amountWei), contract.getPrice()]);
const { priceInCHF, availableShares } = await this.fetchPrice();
const shares = Math.floor(parseFloat(amountChf) / priceInCHF);

return {
amount: amountChf,
shares: shares.toNumber(),
pricePerShare: EvmUtil.fromWeiAmount(pricePerShareRaw).toString(),
shares,
pricePerShare: priceInCHF.toString(),
availableShares,
};
}

async getBrokerbotInfo(): Promise<BrokerbotInfoDto> {
const contract = this.getBrokerbotContract();
const [priceRaw, settings] = await Promise.all([contract.getPrice(), contract.settings()]);

// Settings bitmask: bit 0 = buying enabled, bit 1 = selling enabled
const buyingEnabled = (settings.toNumber() & 1) === 1;
const sellingEnabled = (settings.toNumber() & 2) === 2;
const { priceInCHF, availableShares } = await this.fetchPrice();

return {
brokerbotAddress: BROKERBOT_ADDRESS,
tokenAddress: REALU_TOKEN_ADDRESS,
baseCurrencyAddress: ZCHF_ADDRESS,
pricePerShare: EvmUtil.fromWeiAmount(priceRaw).toString(),
buyingEnabled,
sellingEnabled,
pricePerShare: priceInCHF.toString(),
buyingEnabled: availableShares > 0,
sellingEnabled: true,
availableShares,
};
}
}
1 change: 1 addition & 0 deletions src/shared/services/http.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const MOCK_RESPONSES: { pattern: RegExp; response: any }[] = [
},
{ pattern: /login\.microsoftonline\.com/, response: { access_token: 'mock-token', expires_in: 3600 } },
{ pattern: /api\.applicationinsights\.io/, response: { tables: [{ name: 'PrimaryResult', columns: [], rows: [] }] } },
{ pattern: /aktionariat\.com/, response: { priceInCHF: 1.57, priceInEUR: 1.71, availableShares: 65488 } },
];

@Injectable()
Expand Down
1 change: 0 additions & 1 deletion src/subdomains/core/aml/services/aml-helper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,6 @@ export class AmlHelperService {
errors.push(AmlError.ACCOUNT_IBAN_BLACKLISTED);

const bank = banks.find((b) => b.iban === entity.bankTx.accountIban);
if (bank?.sctInst && !entity.userData.olkypayAllowed) errors.push(AmlError.INSTANT_NOT_ALLOWED);
if (bank?.sctInst && !entity.outputAsset.instantBuyable) errors.push(AmlError.ASSET_NOT_INSTANT_BUYABLE);
if (bank && !bank.amlEnabled) errors.push(AmlError.BANK_DEACTIVATED);
} else if (entity.checkoutTx) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ export class BuyCryptoService {
TransactionUtilService.validateRefund(buyCrypto, {
refundIban: chargebackIban,
chargebackAmount,
chargebackAmountInInputAsset: dto.chargebackAmountInInputAsset,
});

if (
Expand Down
2 changes: 1 addition & 1 deletion src/subdomains/core/buy-crypto/routes/buy/buy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class BuyService {
const { user } = await this.buyRepo.findOne({
where: { id: buyId },
relations: { user: true },
select: ['id', 'user'],
select: { id: true, user: true },
});
const userVolume = await this.getUserVolume(user.id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export class SwapService {
const { user } = await this.swapRepo.findOne({
where: { id: swapId },
relations: { user: true },
select: ['id', 'user'],
select: { id: true, user: true },
});
const userVolume = await this.getUserVolume(user.id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ export class TransactionController {
refundIban: dto.refundTarget ?? refundData.refundTarget,
chargebackCurrency,
creditorData: dto.creditorData,
chargebackAmountInInputAsset: refundData.refundPrice.invert().convert(refundData.refundAmount),
...refundDto,
});
}
Expand Down Expand Up @@ -571,6 +572,7 @@ export class TransactionController {
refundIban: dto.refundTarget ?? refundData.refundTarget,
chargebackCurrency,
creditorData: dto.creditorData,
chargebackAmountInInputAsset: refundData.refundPrice.invert().convert(refundData.refundAmount),
...refundDto,
});
}
Expand Down
3 changes: 3 additions & 0 deletions src/subdomains/core/history/dto/refund-data.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger
import { ActiveDto } from 'src/shared/models/active';
import { AssetDto } from 'src/shared/models/asset/dto/asset.dto';
import { FiatDto } from 'src/shared/models/fiat/dto/fiat.dto';
import { Price } from 'src/subdomains/supporting/pricing/domain/entities/price';

export class RefundFeeDto {
@ApiProperty({ description: 'Network fee in refundAsset' })
Expand Down Expand Up @@ -59,6 +60,8 @@ export class RefundDataDto {
@ApiProperty({ oneOf: [{ $ref: getSchemaPath(AssetDto) }, { $ref: getSchemaPath(FiatDto) }] })
refundAsset: ActiveDto;

refundPrice: Price;

@ApiPropertyOptional({ description: 'IBAN for bank tx or blockchain address for crypto tx' })
refundTarget?: string;

Expand Down
1 change: 1 addition & 0 deletions src/subdomains/core/history/dto/refund-internal.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class BankTxRefund extends BaseRefund {
chargebackCurrency?: string;
chargebackOutput?: FiatOutput;
creditorData?: CreditorData;
chargebackAmountInInputAsset?: number;
}

export class CheckoutTxRefund extends BaseRefund {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export class PaymentRequestMapper {
): PaymentLinkEvmPaymentDto {
const infoUrl = `${Config.url()}/lnurlp/tx/${paymentActivation.payment.uniqueId}`;

const hint = [Blockchain.MONERO, Blockchain.ZANO, Blockchain.SOLANA, Blockchain.TRON].includes(method)
const hint = [Blockchain.MONERO, Blockchain.ZANO, Blockchain.SOLANA, Blockchain.TRON, Blockchain.CARDANO].includes(
method,
)
? `Use this data to create a transaction and sign it. Broadcast the signed transaction to the blockchain and send the transaction hash back via the endpoint ${infoUrl}`
: `Use this data to create a transaction and sign it. Send the signed transaction back as HEX via the endpoint ${infoUrl}. We check the transferred HEX and broadcast the transaction to the blockchain.`;

Expand Down
2 changes: 1 addition & 1 deletion src/subdomains/core/sell-crypto/route/sell.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export class SellService {
const { user } = await this.sellRepo.findOne({
where: { id: sellId },
relations: { user: true },
select: ['id', 'user'],
select: { id: true, user: true },
});
const userVolume = await this.getUserVolume(user.id);

Expand Down
11 changes: 9 additions & 2 deletions src/subdomains/core/transaction/transaction-util.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type RefundValidation = {
refundIban?: string;
refundUser?: User;
chargebackAmount?: number;
chargebackAmountInInputAsset?: number;
};

@Injectable()
Expand Down Expand Up @@ -77,16 +78,22 @@ export class TransactionUtilService {
throw new BadRequestException('Transaction is already returned');

if (entity instanceof BankTxReturn) {
if (dto.chargebackAmount && dto.chargebackAmount > entity.bankTx.amount)
if (
dto.chargebackAmount &&
((dto.chargebackAmount > entity.bankTx.refundAmount && !dto.chargebackAmountInInputAsset) ||
dto.chargebackAmountInInputAsset > entity.bankTx.refundAmount)
)
throw new BadRequestException('You can not refund more than the input amount');
return;
}

if (![CheckStatus.FAIL, CheckStatus.PENDING].includes(entity.amlCheck) || entity.outputAmount)
throw new BadRequestException('Only failed or pending transactions are refundable');

if (
dto.chargebackAmount &&
dto.chargebackAmount > (entity instanceof BuyCrypto && entity.bankTx ? entity.bankTx.amount : entity.inputAmount)
((dto.chargebackAmount > entity.refundAmount && !dto.chargebackAmountInInputAsset) ||
dto.chargebackAmountInInputAsset > entity.refundAmount)
)
throw new BadRequestException('You can not refund more than the input amount');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ export class SumsubService {
'X-App-Access-Sig': signature,
'X-App-Access-Ts': ts,
},
tryCount: 3,
});
}

Expand Down
Loading
Loading