Skip to content

Commit

Permalink
✨ Introducing DCA (#219)
Browse files Browse the repository at this point in the history
  • Loading branch information
florian-bellotti authored Dec 5, 2024
1 parent 513ce82 commit 21b6fd9
Show file tree
Hide file tree
Showing 5 changed files with 343 additions and 0 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"peerDependencies": {
"ethers": "^6.13.0",
"qs": "^6.13.0",
"moment": "^2.30.1",
"starknet": "^6.11.0"
},
"devDependencies": {
Expand All @@ -72,6 +73,7 @@
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"prettier": "3.4.1",
"moment": "2.30.1",
"qs": "6.13.1",
"size-limit": "11.1.6",
"starknet": "6.11.0",
Expand Down
228 changes: 228 additions & 0 deletions src/dca.services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { toBeHex } from 'ethers';
import qs from 'qs';
import { AccountInterface, Call, Signature, TypedData } from 'starknet';
import {
AvnuOptions,
CreateOrderDto,
EstimatedGasFees,
GetOrdersParams,
InvokeSwapResponse,
OrderReceipt,
Page,
PaymasterOptions,
} from './types';
import { getBaseUrl, getRequest, parseResponse, postRequest } from './utils';

const fetchBuildCalls = async (url: string, body: unknown, options?: AvnuOptions): Promise<Call[]> => {
return fetch(`${getBaseUrl(options)}/dca/v1/${url}`, postRequest(body, options)).then((response) =>
parseResponse<Call[]>(response, options?.avnuPublicKey),
);
};

const fetchEstimateFee = async (url: string, body: unknown, options?: AvnuOptions): Promise<EstimatedGasFees> => {
return fetch(`${getBaseUrl(options)}/dca/v1/${url}`, postRequest(body, options))
.then((response) => parseResponse<EstimatedGasFees>(response, options?.avnuPublicKey))
.then((response) => ({
...response,
overallFee: BigInt(response.overallFee),
paymaster: {
...response.paymaster,
gasTokenPrices: response.paymaster.gasTokenPrices.map((price) => ({
...price,
gasFeesInGasToken: BigInt(price.gasFeesInGasToken),
})),
},
}));
};

const fetchBuildTypedData = async (
url: string,
body: object,
gasTokenAddress: string | undefined,
maxGasTokenAmount: bigint | undefined,
options?: AvnuOptions,
): Promise<TypedData> => {
return fetch(
`${getBaseUrl(options)}/dca/v1/${url}`,
postRequest(
{
...body,
gasTokenAddress,
...(maxGasTokenAmount !== undefined && { maxGasTokenAmount: toBeHex(maxGasTokenAmount) }),
},
options,
),
).then((response) => parseResponse<TypedData>(response, options?.avnuPublicKey));
};

const fetchExecute = async (
url: string,
userAddress: string,
typedData: string,
signature: Signature,
options?: AvnuOptions,
): Promise<InvokeSwapResponse> => {
if (Array.isArray(signature)) {
// eslint-disable-next-line no-param-reassign
signature = signature.map((sig) => toBeHex(BigInt(sig)));
} else if (signature.r && signature.s) {
// eslint-disable-next-line no-param-reassign
signature = [toBeHex(BigInt(signature.r)), toBeHex(BigInt(signature.s))];
}
return fetch(
`${getBaseUrl(options)}/dca/v1/${url}`,
postRequest({ userAddress, typedData, signature }, options),
).then((response) => parseResponse<InvokeSwapResponse>(response, options?.avnuPublicKey));
};

const executeDca = async (
account: AccountInterface,
buildCalls: () => Promise<Call[]>,
buildTypedData: () => Promise<TypedData>,
executeTypedData: (typedData: string, signature: Signature) => Promise<InvokeSwapResponse>,
{ gasless = false, gasfree = false, gasTokenAddress, maxGasTokenAmount, executeGaslessTxCallback }: PaymasterOptions,
): Promise<InvokeSwapResponse> => {
if (gasless || gasfree) {
// gasTokenAddress and maxGasTokenAmount are not required for gasfree
if (!gasfree && (!gasTokenAddress || !maxGasTokenAmount)) {
throw Error(`Should provide gasTokenAddress and maxGasTokenAmount when gasless is true`);
}
const typedData = await buildTypedData();
const signature = await account.signMessage(typedData);
if (executeGaslessTxCallback) {
executeGaslessTxCallback();
}
return executeTypedData(JSON.stringify(typedData), signature);
}
return buildCalls()
.then((calls) => account.execute(calls))
.then((value) => ({ transactionHash: value.transaction_hash }));
};

const fetchCreateOrder = async (order: CreateOrderDto, options?: AvnuOptions): Promise<Call[]> =>
fetchBuildCalls('orders', order, options);

const fetchEstimateFeeCreateOrder = async (
order: CreateOrderDto,
traderAddress: string,
options?: AvnuOptions,
): Promise<EstimatedGasFees> => {
return fetchEstimateFee('orders/estimate-fee', order, options);
};

const fetchBuildCreateOrderTypedData = async (
order: CreateOrderDto,
gasTokenAddress: string | undefined,
maxGasTokenAmount: bigint | undefined,
options?: AvnuOptions,
): Promise<TypedData> =>
fetchBuildTypedData('orders/build-typed-data', order, gasTokenAddress, maxGasTokenAmount, options);

const fetchExecuteCreateOrder = async (
userAddress: string,
typedData: string,
signature: Signature,
options?: AvnuOptions,
): Promise<InvokeSwapResponse> => fetchExecute('orders/execute', userAddress, typedData, signature, options);

const fetchCancelOrder = async (orderAddress: string, options?: AvnuOptions): Promise<Call[]> =>
fetchBuildCalls(`orders/${orderAddress}/cancel`, undefined, options);

const fetchEstimateFeeCancelOrder = async (
orderAddress: string,
userAddress: string,
options?: AvnuOptions,
): Promise<EstimatedGasFees> =>
fetchEstimateFee(`orders/${orderAddress}/cancel/estimate-fee`, { traderAddress: userAddress }, options);

const fetchBuildCancelOrderTypedData = async (
orderAddress: string,
userAddress: string,
gasTokenAddress: string | undefined,
maxGasTokenAmount: bigint | undefined,
options?: AvnuOptions,
): Promise<TypedData> =>
fetchBuildTypedData(
`orders/${orderAddress}/cancel/build-typed-data`,
{ traderAddress: userAddress },
gasTokenAddress,
maxGasTokenAmount,
options,
);

const fetchExecuteCancelOrder = async (
userAddress: string,
orderAddress: string,
typedData: string,
signature: Signature,
options?: AvnuOptions,
): Promise<InvokeSwapResponse> =>
fetchExecute(`orders/${orderAddress}/cancel/execute`, userAddress, typedData, signature, options);

const fetchGetOrders = async (
{ traderAddress, status, page, size, sort }: GetOrdersParams,
options?: AvnuOptions,
): Promise<Page<OrderReceipt>> => {
const params = qs.stringify({ traderAddress, status, page, size, sort }, { arrayFormat: 'repeat' });
return fetch(`${getBaseUrl(options)}/dca/v1/orders?${params}`, getRequest(options))
.then((response) => parseResponse<Page<OrderReceipt>>(response, options?.avnuPublicKey))
.then((result) => ({
...result,
content: result.content.map((order) => ({
...order,
sellAmount: BigInt(order.sellAmount),
sellAmountPerCycle: BigInt(order.sellAmountPerCycle),
amountSold: BigInt(order.amountSold),
amountBought: BigInt(order.amountBought),
averageAmountBought: BigInt(order.averageAmountBought),
trades: order.trades.map((trade) => ({
...trade,
sellAmount: BigInt(trade.sellAmount),
buyAmount: trade.buyAmount && BigInt(trade.buyAmount),
})),
})),
}));
};

const executeCreateOrder = async (
account: AccountInterface,
order: CreateOrderDto,
{ gasless = false, gasfree = false, gasTokenAddress, maxGasTokenAmount, executeGaslessTxCallback }: PaymasterOptions,
options?: AvnuOptions,
): Promise<InvokeSwapResponse> => {
return executeDca(
account,
() => fetchCreateOrder(order, options),
() => fetchBuildCreateOrderTypedData(order, gasTokenAddress, maxGasTokenAmount, options),
(typedData, signature) => fetchExecuteCreateOrder(account.address, typedData, signature, options),
{ gasless, gasfree, gasTokenAddress, maxGasTokenAmount, executeGaslessTxCallback },
);
};

const executeCancelOrder = async (
account: AccountInterface,
orderAddress: string,
{ gasless = false, gasfree = false, gasTokenAddress, maxGasTokenAmount, executeGaslessTxCallback }: PaymasterOptions,
options?: AvnuOptions,
): Promise<InvokeSwapResponse> =>
executeDca(
account,
() => fetchCancelOrder(orderAddress, options),
() => fetchBuildCancelOrderTypedData(orderAddress, account.address, gasTokenAddress, maxGasTokenAmount, options),
(typedData, signature) => fetchExecuteCancelOrder(account.address, orderAddress, typedData, signature, options),
{ gasless, gasfree, gasTokenAddress, maxGasTokenAmount, executeGaslessTxCallback },
);

export {
executeCancelOrder,
executeCreateOrder,
fetchBuildCancelOrderTypedData,
fetchBuildCreateOrderTypedData,
fetchCancelOrder,
fetchCreateOrder,
fetchEstimateFeeCancelOrder,
fetchEstimateFeeCreateOrder,
fetchExecuteCancelOrder,
fetchExecuteCreateOrder,
fetchGetOrders,
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './constants';
export * from './dca.services';
export * from './fixtures';
export * from './swap.services';
export * from './token.services';
Expand Down
107 changes: 107 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Duration } from 'moment';
import { Call } from 'starknet';

export interface Pageable {
Expand Down Expand Up @@ -160,3 +161,109 @@ export class ContractError {
public revertError: string,
) {}
}

export interface GetOrdersParams {
traderAddress: string;
status?: OrderStatus;
page?: number;
size?: number;
sort?: Sort;
}

export interface Sort {
field: string;
label: string;
direction: 'ASC' | 'DESC';
}

interface PricingStrategy {
tokenToMinAmount: string | undefined;
tokenToMaxAmount: string | undefined;
}

export enum TradeStatus {
CANCELLED = 'CANCELLED',
PENDING = 'PENDING',
SUCCEEDED = 'SUCCEEDED',
}

interface Trade {
sellAmount: bigint;
sellAmountInUsd: number;
buyAmount?: bigint;
buyAmountInUsd?: number;
expectedTradeDate: Date;
actualTradeDate?: Date;
status: TradeStatus;
txHash?: string;
errorReason?: string;
}

export enum OrderStatus {
INDEXING = 'INDEXING',
ACTIVE = 'ACTIVE',
CLOSED = 'CLOSED',
}

export interface OrderReceipt {
id: string;
blockNumber: number;
timestamp: Date;
traderAddress: string;
orderAddress: string;
creationTransactionHash: string;
orderClassHash: string;
sellTokenAddress: string;
sellAmount: bigint;
sellAmountPerCycle: bigint;
buyTokenAddress: string;
startDate: Date;
endDate: Date;
closeDate?: Date;
frequency: string;
iterations: number;
status: OrderStatus;
pricingStrategy: PricingStrategy | Record<string, never>;
amountSold: bigint;
amountBought: bigint;
averageAmountBought: bigint;
executedTradesCount: number;
cancelledTradesCount: number;
pendingTradesCount: number;
trades: Trade[];
}

export interface EstimatedGasFees {
overallFee: bigint;
overallFeeInUsd: number;
paymaster: EstimatedGasFeesPaymaster;
}

export interface EstimatedGasFeesPaymaster {
active: boolean;
gasTokenPrices: EstimateFeeGasTokenPrice[];
}

export interface EstimateFeeGasTokenPrice {
tokenAddress: string;
gasFeesInGasToken: bigint;
gasFeesInUsd: number;
}

export interface PaymasterOptions {
gasless?: boolean;
gasfree?: boolean;
gasTokenAddress?: string;
maxGasTokenAmount?: bigint;
executeGaslessTxCallback?: () => unknown;
}

export interface CreateOrderDto {
sellTokenAddress: string | undefined;
buyTokenAddress: string | undefined;
sellAmount: string;
sellAmountPerCycle: string;
frequency: Duration;
pricingStrategy: PricingStrategy | Record<string, never>;
traderAddress: string;
}
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4400,6 +4400,11 @@ minimist@^1.2.0, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c"
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==

[email protected]:
version "2.30.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==

[email protected]:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
Expand Down

0 comments on commit 21b6fd9

Please sign in to comment.