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
24 changes: 24 additions & 0 deletions migration/1771500000000-AddSepoliaZCHF.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module.exports = class AddSepoliaZCHF1771500000000 {
name = 'AddSepoliaZCHF1771500000000'

async up(queryRunner) {
await queryRunner.query(`
IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Sepolia/ZCHF')
INSERT INTO "dbo"."asset" (
"name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description",
"comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable",
"financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId",
"approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder"
) VALUES (
'ZCHF', 'Token', 0, 0, '0xd3117681ca462268048f57d106d312ba0b1215ea', 'ZCHF', 'Public', 'Sepolia', 'Sepolia/ZCHF', 'Frankencoin',
0, 18, 0, 0, 0, 0, 0, 0,
'Other', 0, 0, 0, 0, NULL,
1.0, 1.0, 0.93, 99
)
`);
}

async down(queryRunner) {
await queryRunner.query(`DELETE FROM "dbo"."asset" WHERE "uniqueName" = 'Sepolia/ZCHF'`);
}
}
3 changes: 3 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,9 @@ export class Configuration {
url: process.env.REALUNIT_API_URL,
key: process.env.REALUNIT_API_KEY,
},
brokerbotAddress: [Environment.DEV, Environment.LOC].includes(this.environment)
? '0x39c33c2fd5b07b8e890fd2115d4adff7235fc9d2'
: '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d',
bank: {
recipient: process.env.REALUNIT_BANK_RECIPIENT ?? 'RealUnit Schweiz AG',
iban: process.env.REALUNIT_BANK_IBAN ?? 'CH22 0830 7000 5609 4630 9',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from 'src/shared/services/http.service';
import { RealUnitBlockchainService } from '../realunit-blockchain.service';

// Mock viem
const mockReadContract = jest.fn();
jest.mock('viem', () => ({
createPublicClient: jest.fn(() => ({
readContract: mockReadContract,
})),
http: jest.fn(),
parseAbi: jest.fn((abi) => abi),
}));

jest.mock('viem/chains', () => ({
sepolia: { id: 11155111, name: 'Sepolia' },
mainnet: { id: 1, name: 'Ethereum' },
arbitrum: { id: 42161, name: 'Arbitrum' },
optimism: { id: 10, name: 'Optimism' },
polygon: { id: 137, name: 'Polygon' },
base: { id: 8453, name: 'Base' },
bsc: { id: 56, name: 'BSC' },
gnosis: { id: 100, name: 'Gnosis' },
}));

jest.mock('src/config/config', () => ({
GetConfig: jest.fn(() => ({
environment: 'loc',
blockchain: {
realunit: {
api: {
url: 'https://mock-api.example.com',
key: 'mock-api-key',
},
},
sepolia: {
sepoliaChainId: 11155111,
sepoliaGatewayUrl: 'https://sepolia.example.com',
sepoliaApiKey: 'mock-key',
},
ethereum: {
ethChainId: 1,
ethGatewayUrl: 'https://mainnet.example.com',
ethApiKey: 'mock-key',
},
arbitrum: { arbitrumChainId: 42161 },
optimism: { optimismChainId: 10 },
polygon: { polygonChainId: 137 },
base: { baseChainId: 8453 },
bsc: { bscChainId: 56 },
gnosis: { gnosisChainId: 100 },
citrea: { citreaChainId: 0 },
citreaTestnet: { citreaTestnetChainId: 0 },
},
})),
Config: jest.fn(),
Environment: {
DEV: 'dev',
LOC: 'loc',
STG: 'stg',
PRD: 'prd',
},
}));

describe('RealUnitBlockchainService', () => {
let service: RealUnitBlockchainService;
let httpService: jest.Mocked<HttpService>;

const MOCK_BROKERBOT_ADDRESS = '0x39c33c2fd5b07b8e890fd2115d4adff7235fc9d2';

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RealUnitBlockchainService,
{
provide: HttpService,
useValue: {
post: jest.fn(),
},
},
],
}).compile();

service = module.get<RealUnitBlockchainService>(RealUnitBlockchainService);
httpService = module.get(HttpService);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('getBrokerbotSellPrice', () => {
it('should query BrokerBot contract and apply default 0.5% slippage', async () => {
// BrokerBot returns 1000 ZCHF (in Wei) for 10 shares
mockReadContract.mockResolvedValue(BigInt('1000000000000000000000'));

const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10);

// 1000 ZCHF * (1 - 0.005) = 995 ZCHF
expect(result.zchfAmountWei).toBe(BigInt('995000000000000000000'));
});

it('should calculate correctly for 1 share', async () => {
// BrokerBot returns 100 ZCHF for 1 share
mockReadContract.mockResolvedValue(BigInt('100000000000000000000'));

const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 1);

// 100 * 0.995 = 99.5 ZCHF
expect(result.zchfAmountWei).toBe(BigInt('99500000000000000000'));
});

it('should accept custom slippage in basis points', async () => {
// BrokerBot returns 1000 ZCHF for 10 shares
mockReadContract.mockResolvedValue(BigInt('1000000000000000000000'));

const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10, 100); // 1% slippage

// 1000 * (1 - 0.01) = 990 ZCHF
expect(result.zchfAmountWei).toBe(BigInt('990000000000000000000'));
});

it('should handle zero slippage', async () => {
mockReadContract.mockResolvedValue(BigInt('1000000000000000000000'));

const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10, 0);

// Full amount with no slippage
expect(result.zchfAmountWei).toBe(BigInt('1000000000000000000000'));
});

it('should call readContract with correct parameters', async () => {
mockReadContract.mockResolvedValue(BigInt('100000000000000000000'));

await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 5);

expect(mockReadContract).toHaveBeenCalledWith(
expect.objectContaining({
address: MOCK_BROKERBOT_ADDRESS,
functionName: 'getSellPrice',
args: [BigInt(5)],
}),
);
});
});

describe('getBrokerbotInfo', () => {
beforeEach(() => {
httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 500 });
});

it('should return the passed addresses correctly', async () => {
const result = await service.getBrokerbotInfo('0xBrokerbot', '0xREALU', '0xZCHF');

expect(result.brokerbotAddress).toBe('0xBrokerbot');
expect(result.tokenAddress).toBe('0xREALU');
expect(result.baseCurrencyAddress).toBe('0xZCHF');
});

it('should return price from fetchPrice', async () => {
httpService.post.mockResolvedValue({ priceInCHF: 123.45, priceInEUR: 114, availableShares: 200 });

const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ');

expect(result.pricePerShare).toBe('123.45');
expect(result.availableShares).toBe(200);
});

it('should set buyingEnabled to false when availableShares is 0', async () => {
httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 0 });

const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ');

expect(result.buyingEnabled).toBe(false);
});

it('should always set sellingEnabled to true', async () => {
httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 0 });

const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ');

expect(result.sellingEnabled).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { Injectable } from '@nestjs/common';
import { GetConfig } from 'src/config/config';
import { Environment, GetConfig } from 'src/config/config';
import { HttpService } from 'src/shared/services/http.service';
import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache';
import { createPublicClient, http, parseAbi } from 'viem';
import { Blockchain } from '../shared/enums/blockchain.enum';
import { EvmUtil } from '../shared/evm/evm.util';
import {
BrokerbotBuyPriceDto,
BrokerbotInfoDto,
BrokerbotPriceDto,
BrokerbotSharesDto,
} from './dto/realunit-broker.dto';

// Contract addresses
const BROKERBOT_ADDRESS = '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d';
const REALU_TOKEN_ADDRESS = '0x553C7f9C780316FC1D34b8e14ac2465Ab22a090B';
const ZCHF_ADDRESS = '0xb58e61c3098d85632df34eecfb899a1ed80921cb';
const BROKERBOT_ABI = parseAbi([
'function getSellPrice(uint256 shares) view returns (uint256)',
'function getPrice() view returns (uint256)',
]);

interface AktionariatPriceResponse {
priceInCHF: number;
Expand Down Expand Up @@ -113,17 +116,55 @@ export class RealUnitBlockchainService {
};
}

async getBrokerbotInfo(): Promise<BrokerbotInfoDto> {
async getBrokerbotInfo(brokerbotAddr: string, realuAddr: string, zchfAddr: string): Promise<BrokerbotInfoDto> {
const { priceInCHF, availableShares } = await this.fetchPrice();

return {
brokerbotAddress: BROKERBOT_ADDRESS,
tokenAddress: REALU_TOKEN_ADDRESS,
baseCurrencyAddress: ZCHF_ADDRESS,
brokerbotAddress: brokerbotAddr,
tokenAddress: realuAddr,
baseCurrencyAddress: zchfAddr,
pricePerShare: priceInCHF.toString(),
buyingEnabled: availableShares > 0,
sellingEnabled: true,
availableShares,
};
}

async getBrokerbotSellPrice(
brokerbotAddress: string,
shares: number,
slippageBps = 50,
): Promise<{ zchfAmountWei: bigint }> {
const blockchain = [Environment.DEV, Environment.LOC].includes(GetConfig().environment)
? Blockchain.SEPOLIA
: Blockchain.ETHEREUM;

const chainConfig = EvmUtil.getViemChainConfig(blockchain);
if (!chainConfig) {
throw new Error(`No chain config found for ${blockchain}`);
}

const publicClient = createPublicClient({
chain: chainConfig.chain,
transport: http(chainConfig.rpcUrl),
});

// Call getSellPrice on the BrokerBot contract
const sellPriceWei = (await publicClient.readContract({
address: brokerbotAddress as `0x${string}`,
abi: BROKERBOT_ABI,
functionName: 'getSellPrice',
args: [BigInt(shares)],
} as any)) as bigint;

if (sellPriceWei === 0n) {
throw new Error('BrokerBot returned zero sell price');
}

// Apply slippage buffer (reduce expected amount to account for price movement)
const slippageFactor = BigInt(10000 - slippageBps);
const zchfAmountWei = (sellPriceWei * slippageFactor) / BigInt(10000);

return { zchfAmountWei };
}
}
Loading
Loading