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
402 changes: 228 additions & 174 deletions oltinpay/oltinpay-webapp/src/app/exchange/page.tsx

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions oltinpay/oltinpay-webapp/src/hooks/useRates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';

import { api } from '@/lib/api';

/**
* Live OLTIN price from GET /rates (public, feed-derived). `oltin_price_uzd` is
* UZS per 1 OLTIN — the real on-chain price, kept fresh by the feed keeper
* (Block K). Refetched every 30s.
*/
export function useRates() {
return useQuery({
queryKey: ['rates'],
queryFn: () => api.getRates(),
staleTime: 30000,
refetchInterval: 30000,
});
}
70 changes: 15 additions & 55 deletions oltinpay/oltinpay-webapp/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { BalancesResponse, Transaction, UserLookupResult } from '@/types';
import type {
BalancesResponse,
QuoteResponse,
RatesResponse,
Transaction,
UserLookupResult,
} from '@/types';

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.oltinpay.com/api/v1';

Expand Down Expand Up @@ -51,7 +57,7 @@

// Auth
async authenticate(initData: string) {
return this.request<{ access_token: string; user: any; is_new: boolean }>(

Check warning on line 60 in oltinpay/oltinpay-webapp/src/lib/api.ts

View workflow job for this annotation

GitHub Actions / typecheck

Unexpected any. Specify a different type
'/auth/telegram',
{
method: 'POST',
Expand All @@ -62,18 +68,18 @@

// Users
async getMe() {
return this.request<any>('/users/me');

Check warning on line 71 in oltinpay/oltinpay-webapp/src/lib/api.ts

View workflow job for this annotation

GitHub Actions / typecheck

Unexpected any. Specify a different type
}

async updateMe(data: { language?: string }) {
return this.request<any>('/users/me', {

Check warning on line 75 in oltinpay/oltinpay-webapp/src/lib/api.ts

View workflow job for this annotation

GitHub Actions / typecheck

Unexpected any. Specify a different type
method: 'PATCH',
body: JSON.stringify(data),
});
}

async setOltinId(oltin_id: string) {
return this.request<any>('/users/oltin-id', {

Check warning on line 82 in oltinpay/oltinpay-webapp/src/lib/api.ts

View workflow job for this annotation

GitHub Actions / typecheck

Unexpected any. Specify a different type
method: 'POST',
body: JSON.stringify({ oltin_id }),
});
Expand All @@ -89,7 +95,7 @@
}

async searchUsers(q: string) {
return this.request<any[]>(`/users/search?q=${encodeURIComponent(q)}`);

Check warning on line 98 in oltinpay/oltinpay-webapp/src/lib/api.ts

View workflow job for this annotation

GitHub Actions / typecheck

Unexpected any. Specify a different type
}

// Resolve a recipient oltin_id to their wallet address for a signed transfer.
Expand Down Expand Up @@ -175,62 +181,16 @@
return this.request<any[]>('/staking/rewards');
}

// Exchange
async getOrderbook() {
return this.request<any>('/exchange/orderbook');
// Exchange price + quote (por module, feed-derived, UZS). /rates is public;
// /quote is auth-gated — request() attaches the bearer token automatically.
async getRates() {
return this.request<RatesResponse>('/rates');
}

async getPrice() {
return this.request<any>('/exchange/price');
}

// Swap - instant exchange
async getSwapQuote(data: {
side: 'buy' | 'sell';
amount: number;
amount_type?: 'from' | 'to';
}) {
return this.request<{
side: string;
from_currency: string;
from_amount: number;
to_currency: string;
to_amount: number;
price: number;
fee: number;
fee_percent: number;
}>('/exchange/swap/quote', {
method: 'POST',
body: JSON.stringify({
side: data.side,
amount: data.amount,
amount_type: data.amount_type || 'from',
}),
});
}

async executeSwap(data: {
side: 'buy' | 'sell';
amount: number;
amount_type?: 'from' | 'to';
}) {
return this.request<{
success: boolean;
side: string;
from_currency: string;
from_amount: number;
to_currency: string;
to_amount: number;
price: number;
fee: number;
}>('/exchange/swap', {
method: 'POST',
body: JSON.stringify({
side: data.side,
amount: data.amount,
amount_type: data.amount_type || 'from',
}),
});
async getQuote(side: 'buy' | 'sell', amount: string) {
return this.request<QuoteResponse>(
`/quote?side=${side}&amount=${encodeURIComponent(amount)}`
);
}

// Legacy orders
Expand Down
47 changes: 46 additions & 1 deletion oltinpay/oltinpay-webapp/src/lib/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {zksyncSepoliaTestnet} from 'viem/chains';
import type {Address, Hex} from 'viem';
import type {HDAccount} from 'viem/accounts';

import {CONTRACTS, ERC20_ABI, STAKING_ABI} from './contracts';
import {CONTRACTS, ERC20_ABI, EXCHANGE_ABI, STAKING_ABI} from './contracts';

export const publicClient = createPublicClient({
chain: zksyncSepoliaTestnet,
Expand Down Expand Up @@ -126,3 +126,48 @@ export async function claimStakingReward(account: HDAccount): Promise<Hex> {
functionName: 'claim',
});
}

// Exchange (buy/sell). Both are two-step: approve the Exchange to move the input
// token, then buy/sell. `buy` pulls UZD + mints OLTIN; `sell` burns OLTIN + pays
// UZD from the treasury. minOut is the client-computed slippage floor.
export async function approveForExchange(
account: HDAccount,
token: Address,
amount: bigint,
): Promise<Hex> {
const wallet = makeWalletClient(account);
return wallet.writeContract({
address: token,
abi: ERC20_ABI,
functionName: 'approve',
args: [CONTRACTS.EXCHANGE, amount],
});
}

export async function exchangeBuy(
account: HDAccount,
uzdInWei: bigint,
minOltinOut: bigint,
): Promise<Hex> {
const wallet = makeWalletClient(account);
return wallet.writeContract({
address: CONTRACTS.EXCHANGE,
abi: EXCHANGE_ABI,
functionName: 'buy',
args: [uzdInWei, minOltinOut],
});
}

export async function exchangeSell(
account: HDAccount,
oltinInWei: bigint,
minUzdOut: bigint,
): Promise<Hex> {
const wallet = makeWalletClient(account);
return wallet.writeContract({
address: CONTRACTS.EXCHANGE,
abi: EXCHANGE_ABI,
functionName: 'sell',
args: [oltinInWei, minUzdOut],
});
}
36 changes: 33 additions & 3 deletions oltinpay/oltinpay-webapp/src/lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import type {Address} from 'viem';
export const ZKSYNC_SEPOLIA_CHAIN_ID = 300;

export const CONTRACTS = {
// OltinTokenV3 (OLTIN) — the live token. The retired V2 (0x4A56B78D…) is gone;
// UZD/STAKING below are still V2-retired and get repointed in Stages 4/5.
// Live V3/V3.1 addresses. STAKING is still V2-retired (repointed in Stage 4).
OLTIN: '0x906bcf6c92ed1b30aA453c69eB40aeDbb3d5B3A5' as Address,
UZD: '0x95b30Be4fdE1C48d7C5dC22C1EBA061219125A32' as Address,
UZD: '0x51232fd0065bD2ca50551761Acef476E3CDf02aA' as Address,
EXCHANGE: '0x99D733E64eb60c3B3D5f3DeDe4CC4adC92BCd1c9' as Address,
STAKING: '0x63e537A3a150d06035151E29904C1640181C8314' as Address,
} as const;

Expand Down Expand Up @@ -125,3 +125,33 @@ export const STAKING_ABI = [
outputs: [{type: 'uint256'}],
},
] as const;

export const EXCHANGE_ABI = [
{
type: 'function',
name: 'buy',
stateMutability: 'nonpayable',
inputs: [
{name: 'uzdInWei', type: 'uint256'},
{name: 'minOltinOut', type: 'uint256'},
],
outputs: [{name: 'oltinOutWei', type: 'uint256'}],
},
{
type: 'function',
name: 'sell',
stateMutability: 'nonpayable',
inputs: [
{name: 'oltinInWei', type: 'uint256'},
{name: 'minUzdOut', type: 'uint256'},
],
outputs: [{name: 'uzdOutWei', type: 'uint256'}],
},
{
type: 'function',
name: 'treasuryBalance',
stateMutability: 'view',
inputs: [],
outputs: [{type: 'uint256'}],
},
] as const;
37 changes: 36 additions & 1 deletion oltinpay/oltinpay-webapp/src/lib/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';

import { formatToken, parseTokenAmount } from './format';
import { computeMinOut, formatToken, parseTokenAmount } from './format';

describe('formatToken', () => {
it('should group 1e24 wei (1,000,000 tokens) with a thousands separator', () => {
Expand Down Expand Up @@ -59,3 +59,38 @@ describe('parseTokenAmount', () => {
expect(parseTokenAmount('abc')).toBeNull();
});
});

describe('computeMinOut', () => {
it('applies a 1% slippage floor (default)', () => {
expect(computeMinOut('1000')).toBe(BigInt(990));
});

it('floors the integer division', () => {
expect(computeMinOut('100')).toBe(BigInt(99)); // 100*9900/10000 = 99
});

it('handles a large wei value without precision loss', () => {
expect(computeMinOut('1000000000000000000')).toBe(BigInt('990000000000000000'));
});

it('honors a custom slippage (2%)', () => {
expect(computeMinOut('1000', 200)).toBe(BigInt(980));
});

it('returns null for null / empty estimate', () => {
expect(computeMinOut(null)).toBeNull();
expect(computeMinOut('')).toBeNull();
});

it('returns null for a zero / non-positive estimate', () => {
expect(computeMinOut('0')).toBeNull();
});

it('returns null when the floored minOut is 0 (amount too small)', () => {
expect(computeMinOut('1')).toBeNull(); // 1*9900/10000 = 0
});

it('returns null for a non-numeric estimate', () => {
expect(computeMinOut('abc')).toBeNull();
});
});
24 changes: 24 additions & 0 deletions oltinpay/oltinpay-webapp/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,27 @@ export function parseTokenAmount(raw: string, decimals = TOKEN_DECIMALS): bigint
return null;
}
}

/**
* Client-side slippage floor for an Exchange swap. The backend `/quote` returns
* an exact-integer estimate (`estimated_out_wei`, same floor formula as the
* on-chain swap) but no minOut — we floor it by the slippage tolerance
* (default 1% = 100 bps). Returns null when the estimate is missing / non-positive
* / not a number, OR the floored minOut would be 0 — the on-chain buy requires
* `minOltinOut > 0`, and a 0 floor means the amount is too small to swap.
*/
export function computeMinOut(
estimatedOutWei: string | null,
slippageBps = 100,
): bigint | null {
if (!estimatedOutWei) return null;
let est: bigint;
try {
est = BigInt(estimatedOutWei);
} catch {
return null;
}
if (est <= BigInt(0)) return null;
const minOut = (est * BigInt(10000 - slippageBps)) / BigInt(10000);
return minOut > BigInt(0) ? minOut : null;
}
30 changes: 30 additions & 0 deletions oltinpay/oltinpay-webapp/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,36 @@ export interface UserLookupResult {
wallet_address: string | null;
}

// Exchange price + quote (por module, feed-derived — all UZS, not USD).
export interface FeedReading {
answer: string;
decimals: number;
updated_at: number;
}

export interface RatesResponse {
xau_usd: FeedReading;
uzs_usd: FeedReading;
oltin_price_uzd: number; // UZS per 1 OLTIN (1 gram of gold)
}

export type QuoteSide = 'buy' | 'sell';

// GET /quote?side=&amount= — estimate only (no minOut/fee); estimated_out_wei is
// an exact integer wei string (same floor formula as the on-chain swap).
export interface QuoteResponse {
oltin_price_uzd: number;
xau_answer: string;
uzs_answer: string;
xau_updated_at: number;
uzs_updated_at: number;
side: QuoteSide | null;
amount: number | null;
estimated_out: number | null;
estimated_out_wei: string | null;
estimated_out_symbol: string | null;
}

export interface Transfer {
id: string;
direction: 'sent' | 'received';
Expand Down
Loading