Skip to content

Commit 70810de

Browse files
authored
Merge pull request #3259 from DFXswiss/develop
Release: develop -> main
2 parents 4408736 + 48e15dd commit 70810de

15 files changed

Lines changed: 1552 additions & 62 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
module.exports = class AddSepoliaZCHF1771500000000 {
2+
name = 'AddSepoliaZCHF1771500000000'
3+
4+
async up(queryRunner) {
5+
await queryRunner.query(`
6+
IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Sepolia/ZCHF')
7+
INSERT INTO "dbo"."asset" (
8+
"name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description",
9+
"comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable",
10+
"financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId",
11+
"approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder"
12+
) VALUES (
13+
'ZCHF', 'Token', 0, 0, '0xd3117681ca462268048f57d106d312ba0b1215ea', 'ZCHF', 'Public', 'Sepolia', 'Sepolia/ZCHF', 'Frankencoin',
14+
0, 18, 0, 0, 0, 0, 0, 0,
15+
'Other', 0, 0, 0, 0, NULL,
16+
1.0, 1.0, 0.93, 99
17+
)
18+
`);
19+
}
20+
21+
async down(queryRunner) {
22+
await queryRunner.query(`DELETE FROM "dbo"."asset" WHERE "uniqueName" = 'Sepolia/ZCHF'`);
23+
}
24+
}

src/config/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,9 @@ export class Configuration {
995995
url: process.env.REALUNIT_API_URL,
996996
key: process.env.REALUNIT_API_KEY,
997997
},
998+
brokerbotAddress: [Environment.DEV, Environment.LOC].includes(this.environment)
999+
? '0x39c33c2fd5b07b8e890fd2115d4adff7235fc9d2'
1000+
: '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d',
9981001
bank: {
9991002
recipient: process.env.REALUNIT_BANK_RECIPIENT ?? 'RealUnit Schweiz AG',
10001003
iban: process.env.REALUNIT_BANK_IBAN ?? 'CH22 0830 7000 5609 4630 9',
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { HttpService } from 'src/shared/services/http.service';
3+
import { RealUnitBlockchainService } from '../realunit-blockchain.service';
4+
5+
// Mock viem
6+
const mockReadContract = jest.fn();
7+
jest.mock('viem', () => ({
8+
createPublicClient: jest.fn(() => ({
9+
readContract: mockReadContract,
10+
})),
11+
http: jest.fn(),
12+
parseAbi: jest.fn((abi) => abi),
13+
}));
14+
15+
jest.mock('viem/chains', () => ({
16+
sepolia: { id: 11155111, name: 'Sepolia' },
17+
mainnet: { id: 1, name: 'Ethereum' },
18+
arbitrum: { id: 42161, name: 'Arbitrum' },
19+
optimism: { id: 10, name: 'Optimism' },
20+
polygon: { id: 137, name: 'Polygon' },
21+
base: { id: 8453, name: 'Base' },
22+
bsc: { id: 56, name: 'BSC' },
23+
gnosis: { id: 100, name: 'Gnosis' },
24+
}));
25+
26+
jest.mock('src/config/config', () => ({
27+
GetConfig: jest.fn(() => ({
28+
environment: 'loc',
29+
blockchain: {
30+
realunit: {
31+
api: {
32+
url: 'https://mock-api.example.com',
33+
key: 'mock-api-key',
34+
},
35+
},
36+
sepolia: {
37+
sepoliaChainId: 11155111,
38+
sepoliaGatewayUrl: 'https://sepolia.example.com',
39+
sepoliaApiKey: 'mock-key',
40+
},
41+
ethereum: {
42+
ethChainId: 1,
43+
ethGatewayUrl: 'https://mainnet.example.com',
44+
ethApiKey: 'mock-key',
45+
},
46+
arbitrum: { arbitrumChainId: 42161 },
47+
optimism: { optimismChainId: 10 },
48+
polygon: { polygonChainId: 137 },
49+
base: { baseChainId: 8453 },
50+
bsc: { bscChainId: 56 },
51+
gnosis: { gnosisChainId: 100 },
52+
citrea: { citreaChainId: 0 },
53+
citreaTestnet: { citreaTestnetChainId: 0 },
54+
},
55+
})),
56+
Config: jest.fn(),
57+
Environment: {
58+
DEV: 'dev',
59+
LOC: 'loc',
60+
STG: 'stg',
61+
PRD: 'prd',
62+
},
63+
}));
64+
65+
describe('RealUnitBlockchainService', () => {
66+
let service: RealUnitBlockchainService;
67+
let httpService: jest.Mocked<HttpService>;
68+
69+
const MOCK_BROKERBOT_ADDRESS = '0x39c33c2fd5b07b8e890fd2115d4adff7235fc9d2';
70+
71+
beforeEach(async () => {
72+
const module: TestingModule = await Test.createTestingModule({
73+
providers: [
74+
RealUnitBlockchainService,
75+
{
76+
provide: HttpService,
77+
useValue: {
78+
post: jest.fn(),
79+
},
80+
},
81+
],
82+
}).compile();
83+
84+
service = module.get<RealUnitBlockchainService>(RealUnitBlockchainService);
85+
httpService = module.get(HttpService);
86+
});
87+
88+
afterEach(() => {
89+
jest.clearAllMocks();
90+
});
91+
92+
describe('getBrokerbotSellPrice', () => {
93+
it('should query BrokerBot contract and apply default 0.5% slippage', async () => {
94+
// BrokerBot returns 1000 ZCHF (in Wei) for 10 shares
95+
mockReadContract.mockResolvedValue(BigInt('1000000000000000000000'));
96+
97+
const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10);
98+
99+
// 1000 ZCHF * (1 - 0.005) = 995 ZCHF
100+
expect(result.zchfAmountWei).toBe(BigInt('995000000000000000000'));
101+
});
102+
103+
it('should calculate correctly for 1 share', async () => {
104+
// BrokerBot returns 100 ZCHF for 1 share
105+
mockReadContract.mockResolvedValue(BigInt('100000000000000000000'));
106+
107+
const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 1);
108+
109+
// 100 * 0.995 = 99.5 ZCHF
110+
expect(result.zchfAmountWei).toBe(BigInt('99500000000000000000'));
111+
});
112+
113+
it('should accept custom slippage in basis points', async () => {
114+
// BrokerBot returns 1000 ZCHF for 10 shares
115+
mockReadContract.mockResolvedValue(BigInt('1000000000000000000000'));
116+
117+
const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10, 100); // 1% slippage
118+
119+
// 1000 * (1 - 0.01) = 990 ZCHF
120+
expect(result.zchfAmountWei).toBe(BigInt('990000000000000000000'));
121+
});
122+
123+
it('should handle zero slippage', async () => {
124+
mockReadContract.mockResolvedValue(BigInt('1000000000000000000000'));
125+
126+
const result = await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 10, 0);
127+
128+
// Full amount with no slippage
129+
expect(result.zchfAmountWei).toBe(BigInt('1000000000000000000000'));
130+
});
131+
132+
it('should call readContract with correct parameters', async () => {
133+
mockReadContract.mockResolvedValue(BigInt('100000000000000000000'));
134+
135+
await service.getBrokerbotSellPrice(MOCK_BROKERBOT_ADDRESS, 5);
136+
137+
expect(mockReadContract).toHaveBeenCalledWith(
138+
expect.objectContaining({
139+
address: MOCK_BROKERBOT_ADDRESS,
140+
functionName: 'getSellPrice',
141+
args: [BigInt(5)],
142+
}),
143+
);
144+
});
145+
});
146+
147+
describe('getBrokerbotInfo', () => {
148+
beforeEach(() => {
149+
httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 500 });
150+
});
151+
152+
it('should return the passed addresses correctly', async () => {
153+
const result = await service.getBrokerbotInfo('0xBrokerbot', '0xREALU', '0xZCHF');
154+
155+
expect(result.brokerbotAddress).toBe('0xBrokerbot');
156+
expect(result.tokenAddress).toBe('0xREALU');
157+
expect(result.baseCurrencyAddress).toBe('0xZCHF');
158+
});
159+
160+
it('should return price from fetchPrice', async () => {
161+
httpService.post.mockResolvedValue({ priceInCHF: 123.45, priceInEUR: 114, availableShares: 200 });
162+
163+
const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ');
164+
165+
expect(result.pricePerShare).toBe('123.45');
166+
expect(result.availableShares).toBe(200);
167+
});
168+
169+
it('should set buyingEnabled to false when availableShares is 0', async () => {
170+
httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 0 });
171+
172+
const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ');
173+
174+
expect(result.buyingEnabled).toBe(false);
175+
});
176+
177+
it('should always set sellingEnabled to true', async () => {
178+
httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 0 });
179+
180+
const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ');
181+
182+
expect(result.sellingEnabled).toBe(true);
183+
});
184+
});
185+
});

src/integration/blockchain/realunit/realunit-blockchain.service.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { Injectable } from '@nestjs/common';
2-
import { GetConfig } from 'src/config/config';
2+
import { Environment, GetConfig } from 'src/config/config';
33
import { HttpService } from 'src/shared/services/http.service';
44
import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache';
5+
import { createPublicClient, http, parseAbi } from 'viem';
6+
import { Blockchain } from '../shared/enums/blockchain.enum';
7+
import { EvmUtil } from '../shared/evm/evm.util';
58
import {
69
BrokerbotBuyPriceDto,
710
BrokerbotInfoDto,
811
BrokerbotPriceDto,
912
BrokerbotSharesDto,
1013
} from './dto/realunit-broker.dto';
1114

12-
// Contract addresses
13-
const BROKERBOT_ADDRESS = '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d';
14-
const REALU_TOKEN_ADDRESS = '0x553C7f9C780316FC1D34b8e14ac2465Ab22a090B';
15-
const ZCHF_ADDRESS = '0xb58e61c3098d85632df34eecfb899a1ed80921cb';
15+
const BROKERBOT_ABI = parseAbi([
16+
'function getSellPrice(uint256 shares) view returns (uint256)',
17+
'function getPrice() view returns (uint256)',
18+
]);
1619

1720
interface AktionariatPriceResponse {
1821
priceInCHF: number;
@@ -113,17 +116,55 @@ export class RealUnitBlockchainService {
113116
};
114117
}
115118

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

119122
return {
120-
brokerbotAddress: BROKERBOT_ADDRESS,
121-
tokenAddress: REALU_TOKEN_ADDRESS,
122-
baseCurrencyAddress: ZCHF_ADDRESS,
123+
brokerbotAddress: brokerbotAddr,
124+
tokenAddress: realuAddr,
125+
baseCurrencyAddress: zchfAddr,
123126
pricePerShare: priceInCHF.toString(),
124127
buyingEnabled: availableShares > 0,
125128
sellingEnabled: true,
126129
availableShares,
127130
};
128131
}
132+
133+
async getBrokerbotSellPrice(
134+
brokerbotAddress: string,
135+
shares: number,
136+
slippageBps = 50,
137+
): Promise<{ zchfAmountWei: bigint }> {
138+
const blockchain = [Environment.DEV, Environment.LOC].includes(GetConfig().environment)
139+
? Blockchain.SEPOLIA
140+
: Blockchain.ETHEREUM;
141+
142+
const chainConfig = EvmUtil.getViemChainConfig(blockchain);
143+
if (!chainConfig) {
144+
throw new Error(`No chain config found for ${blockchain}`);
145+
}
146+
147+
const publicClient = createPublicClient({
148+
chain: chainConfig.chain,
149+
transport: http(chainConfig.rpcUrl),
150+
});
151+
152+
// Call getSellPrice on the BrokerBot contract
153+
const sellPriceWei = (await publicClient.readContract({
154+
address: brokerbotAddress as `0x${string}`,
155+
abi: BROKERBOT_ABI,
156+
functionName: 'getSellPrice',
157+
args: [BigInt(shares)],
158+
} as any)) as bigint;
159+
160+
if (sellPriceWei === 0n) {
161+
throw new Error('BrokerBot returned zero sell price');
162+
}
163+
164+
// Apply slippage buffer (reduce expected amount to account for price movement)
165+
const slippageFactor = BigInt(10000 - slippageBps);
166+
const zchfAmountWei = (sellPriceWei * slippageFactor) / BigInt(10000);
167+
168+
return { zchfAmountWei };
169+
}
129170
}

0 commit comments

Comments
 (0)