diff --git a/package.json b/package.json index 72a7256..75da3eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@orionprotocol/sdk", - "version": "0.16.0-rc.5", + "version": "0.16.0-rc.6", "description": "Orion Protocol SDK", "main": "./lib/esm/index.js", "module": "./lib/esm/index.js", diff --git a/src/constants/cfdOrderTypes.ts b/src/constants/cfdOrderTypes.ts new file mode 100644 index 0000000..cbe5247 --- /dev/null +++ b/src/constants/cfdOrderTypes.ts @@ -0,0 +1,15 @@ +const CFD_ORDER_TYPES = { + CFDOrder: [ + { name: 'senderAddress', type: 'address' }, + { name: 'matcherAddress', type: 'address' }, + { name: 'instrumentAddress', type: 'address' }, + { name: 'amount', type: 'uint64' }, + { name: 'price', type: 'uint64' }, + { name: 'matcherFee', type: 'uint64' }, + { name: 'nonce', type: 'uint64' }, + { name: 'expiration', type: 'uint64' }, + { name: 'buySide', type: 'uint8' }, + ], +}; + +export default CFD_ORDER_TYPES; diff --git a/src/crypt/hashCFDOrder.ts b/src/crypt/hashCFDOrder.ts new file mode 100644 index 0000000..0ee0654 --- /dev/null +++ b/src/crypt/hashCFDOrder.ts @@ -0,0 +1,31 @@ +import { ethers } from 'ethers'; +import { CFDOrder } from '../types'; + +const hashCFDOrder = (order: CFDOrder) => ethers.utils.solidityKeccak256( + [ + 'uint8', + 'address', + 'address', + 'address', + 'uint64', + 'uint64', + 'uint64', + 'uint64', + 'uint64', + 'uint8', + ], + [ + '0x03', + order.senderAddress, + order.matcherAddress, + order.instrumentAddress, + order.amount, + order.price, + order.matcherFee, + order.nonce, + order.expiration, + order.buySide ? '0x01' : '0x00', + ], +); + +export default hashCFDOrder; diff --git a/src/crypt/index.ts b/src/crypt/index.ts index 4b1f786..6a0c8d8 100644 --- a/src/crypt/index.ts +++ b/src/crypt/index.ts @@ -1,4 +1,5 @@ export { default as signCancelOrder } from './signCancelOrder'; export { default as signCancelOrderPersonal } from './signCancelOrderPersonal'; export { default as signOrder } from './signOrder'; +export { default as signCFDOrder } from './signCFDOrder'; export { default as signOrderPersonal } from './signOrderPersonal'; diff --git a/src/crypt/signCFDOrder.ts b/src/crypt/signCFDOrder.ts new file mode 100644 index 0000000..7380774 --- /dev/null +++ b/src/crypt/signCFDOrder.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-underscore-dangle */ +import { TypedDataSigner } from '@ethersproject/abstract-signer'; +import BigNumber from 'bignumber.js'; +import { ethers } from 'ethers'; +import { joinSignature, splitSignature } from 'ethers/lib/utils'; +import { INTERNAL_ORION_PRECISION } from '../constants'; +import { CFDOrder, SignedCFDOrder, SupportedChainId } from '../types'; +import normalizeNumber from '../utils/normalizeNumber'; +import getDomainData from './getDomainData'; +import signCFDOrderPersonal from "./signCFDOrderPersonal"; +import hashCFDOrder from "./hashCFDOrder"; +import CFD_ORDER_TYPES from "../constants/cfdOrderTypes"; + +const DEFAULT_EXPIRATION = 29 * 24 * 60 * 60 * 1000; // 29 days + +type SignerWithTypedDataSign = ethers.Signer & TypedDataSigner; + +export const signCFDOrder = async ( + instrumentAddress: string, + side: 'BUY' | 'SELL', + price: BigNumber.Value, + amount: BigNumber.Value, + matcherFee: BigNumber.Value, + senderAddress: string, + matcherAddress: string, + usePersonalSign: boolean, + signer: ethers.Signer, + chainId: SupportedChainId, +) => { + const nonce = Date.now(); + const expiration = nonce + DEFAULT_EXPIRATION; + + const order: CFDOrder = { + senderAddress, + matcherAddress, + instrumentAddress, + amount: normalizeNumber( + amount, + INTERNAL_ORION_PRECISION, + BigNumber.ROUND_FLOOR, + ).toNumber(), + price: normalizeNumber( + price, + INTERNAL_ORION_PRECISION, + BigNumber.ROUND_FLOOR, + ).toNumber(), + matcherFee: normalizeNumber( + matcherFee, + INTERNAL_ORION_PRECISION, + BigNumber.ROUND_CEIL, // ROUND_CEIL because we don't want get "not enough fee" error + ).toNumber(), + nonce, + expiration, + buySide: side === 'BUY' ? 1 : 0, + isPersonalSign: usePersonalSign, + }; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const typedDataSigner = signer as SignerWithTypedDataSign; + const signature = usePersonalSign + ? await signCFDOrderPersonal(order, signer) + : await typedDataSigner._signTypedData( + getDomainData(chainId), + CFD_ORDER_TYPES, + order, + ); + + // https://github.com/poap-xyz/poap-fun/pull/62#issue-928290265 + // "Signature's v was always send as 27 or 28, but from Ledger was 0 or 1" + const fixedSignature = joinSignature(splitSignature(signature)); + + if (!fixedSignature) throw new Error("Can't sign order"); + + const signedOrder: SignedCFDOrder = { + ...order, + id: hashCFDOrder(order), + signature: fixedSignature, + }; + return signedOrder; +}; + +export default signCFDOrder; diff --git a/src/crypt/signCFDOrderPersonal.ts b/src/crypt/signCFDOrderPersonal.ts new file mode 100644 index 0000000..fad91c0 --- /dev/null +++ b/src/crypt/signCFDOrderPersonal.ts @@ -0,0 +1,30 @@ +import { ethers } from 'ethers'; +import { CFDOrder } from '../types'; + +const { arrayify, joinSignature, splitSignature } = ethers.utils; + +const signCFDOrderPersonal = async (order: CFDOrder, signer: ethers.Signer) => { + const message = ethers.utils.solidityKeccak256( + [ + 'string', 'address', 'address', 'address', 'uint64', 'uint64', 'uint64', 'uint64', 'uint64', 'uint8', + ], + [ + 'order', + order.senderAddress, + order.matcherAddress, + order.instrumentAddress, + order.amount, + order.price, + order.matcherFee, + order.nonce, + order.expiration, + order.buySide, + ], + ); + const signature = await signer.signMessage(arrayify(message)); + + // NOTE: metamask broke sig.v value and we fix it in next line + return joinSignature(splitSignature(signature)); +}; + +export default signCFDOrderPersonal; diff --git a/src/services/OrionAggregator/ws/MessageType.ts b/src/services/OrionAggregator/ws/MessageType.ts index fe4bc7f..323e836 100644 --- a/src/services/OrionAggregator/ws/MessageType.ts +++ b/src/services/OrionAggregator/ws/MessageType.ts @@ -7,6 +7,7 @@ const MessageType = { ASSET_PAIRS_CONFIG_UPDATE: 'apcu', ASSET_PAIR_CONFIG_UPDATE: 'apiu', ADDRESS_UPDATE: 'au', + CFD_ADDRESS_UPDATE: 'auf', BROKER_TRADABLE_ATOMIC_SWAP_ASSETS_BALANCE_UPDATE: 'btasabu', UNSUBSCRIPTION_DONE: 'ud', } as const; diff --git a/src/services/OrionAggregator/ws/SubscriptionType.ts b/src/services/OrionAggregator/ws/SubscriptionType.ts index 66b0c0c..741dad1 100644 --- a/src/services/OrionAggregator/ws/SubscriptionType.ts +++ b/src/services/OrionAggregator/ws/SubscriptionType.ts @@ -3,6 +3,7 @@ const SubscriptionType = { ASSET_PAIR_CONFIG_UPDATES_SUBSCRIBE: 'apius', AGGREGATED_ORDER_BOOK_UPDATES_SUBSCRIBE: 'aobus', ADDRESS_UPDATES_SUBSCRIBE: 'aus', + CFD_ADDRESS_UPDATES_SUBSCRIBE: 'ausf', BROKER_TRADABLE_ATOMIC_SWAP_ASSETS_BALANCE_UPDATES_SUBSCRIBE: 'btasabus', SWAP_SUBSCRIBE: 'ss', } as const; diff --git a/src/services/OrionAggregator/ws/index.ts b/src/services/OrionAggregator/ws/index.ts index 1f9e451..ca58f78 100644 --- a/src/services/OrionAggregator/ws/index.ts +++ b/src/services/OrionAggregator/ws/index.ts @@ -11,10 +11,11 @@ import { import UnsubscriptionType from './UnsubscriptionType'; import { SwapInfoByAmountIn, SwapInfoByAmountOut, SwapInfoBase, - FullOrder, OrderUpdate, AssetPairUpdate, OrderbookItem, Balance, Exchange, + FullOrder, OrderUpdate, AssetPairUpdate, OrderbookItem, Balance, Exchange, CFDBalance, } from '../../../types'; import unsubscriptionDoneSchema from './schemas/unsubscriptionDoneSchema'; import assetPairConfigSchema from './schemas/assetPairConfigSchema'; +import cfdAddressUpdateSchema from "./schemas/cfdAddressUpdateSchema"; // import errorSchema from './schemas/errorSchema'; const UNSUBSCRIBE = 'u'; @@ -85,13 +86,31 @@ type AddressUpdateInitial = { orders?: FullOrder[] // The field is not defined if the user has no orders } +type CfdAddressUpdateUpdate = { + kind: 'update', + balances: CFDBalance[], + order?: OrderUpdate | FullOrder +} + +type CfdAddressUpdateInitial = { + kind: 'initial', + balances: CFDBalance[], + orders?: FullOrder[] // The field is not defined if the user has no orders +} + type AddressUpdateSubscription = { payload: string, callback: (data: AddressUpdateUpdate | AddressUpdateInitial) => void, } +type CfdAddressUpdateSubscription = { + payload: string, + callback: (data: CfdAddressUpdateUpdate | CfdAddressUpdateInitial) => void, +} + type Subscription = { [SubscriptionType.ADDRESS_UPDATES_SUBSCRIBE]: AddressUpdateSubscription, + [SubscriptionType.CFD_ADDRESS_UPDATES_SUBSCRIBE]: CfdAddressUpdateSubscription, [SubscriptionType.AGGREGATED_ORDER_BOOK_UPDATES_SUBSCRIBE]: AggregatedOrderbookSubscription, [SubscriptionType.ASSET_PAIRS_CONFIG_UPDATES_SUBSCRIBE]: PairsConfigSubscription, [SubscriptionType.ASSET_PAIR_CONFIG_UPDATES_SUBSCRIBE]: PairConfigSubscription, @@ -211,6 +230,15 @@ class OrionAggregatorWS { delete this.subscriptions[SubscriptionType.ADDRESS_UPDATES_SUBSCRIBE]?.[key]; } } + + const aufSubscriptions = this.subscriptions[SubscriptionType.CFD_ADDRESS_UPDATES_SUBSCRIBE]; + if (aufSubscriptions) { + const targetAufSub = Object.entries(aufSubscriptions).find(([, value]) => value?.payload === subscription); + if (targetAufSub) { + const [key] = targetAufSub; + delete this.subscriptions[SubscriptionType.CFD_ADDRESS_UPDATES_SUBSCRIBE]?.[key]; + } + } } else if (uuidValidate(subscription)) { // is swap info subscription (contains hyphen) delete this.subscriptions[SubscriptionType.SWAP_SUBSCRIBE]?.[subscription]; @@ -276,6 +304,7 @@ class OrionAggregatorWS { initMessageSchema, pingPongMessageSchema, addressUpdateSchema, + cfdAddressUpdateSchema, assetPairsConfigSchema, assetPairConfigSchema, brokerMessageSchema, @@ -415,6 +444,48 @@ class OrionAggregatorWS { }); } break; + case MessageType.CFD_ADDRESS_UPDATE: { + const balances = json.b ?? [] + switch (json.k) { // message kind + case 'i': { // initial + const fullOrders = json.o + ? json.o.reduce((prev, o) => { + prev.push(o); + + return prev; + }, []) + : undefined; + + this.subscriptions[ + SubscriptionType.CFD_ADDRESS_UPDATES_SUBSCRIBE + ]?.[json.id]?.callback({ + kind: 'initial', + orders: fullOrders, + balances, + }); + } + break; + case 'u': { // update + let orderUpdate: OrderUpdate | FullOrder | undefined; + if (json.o) { + const firstOrder = json.o[0]; + orderUpdate = firstOrder; + } + + this.subscriptions[ + SubscriptionType.CFD_ADDRESS_UPDATES_SUBSCRIBE + ]?.[json.id]?.callback({ + kind: 'update', + order: orderUpdate, + balances, + }); + } + break; + default: + break; + } + } + break; case MessageType.ADDRESS_UPDATE: { const balances = json.b ? Object.entries(json.b) diff --git a/src/services/OrionAggregator/ws/schemas/cfdAddressUpdateSchema.ts b/src/services/OrionAggregator/ws/schemas/cfdAddressUpdateSchema.ts new file mode 100644 index 0000000..410e251 --- /dev/null +++ b/src/services/OrionAggregator/ws/schemas/cfdAddressUpdateSchema.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { fullOrderSchema, orderUpdateSchema } from "./addressUpdateSchema"; +import baseMessageSchema from "./baseMessageSchema"; +import MessageType from "../MessageType"; +import cfdBalancesSchema from "./cfdBalancesSchema"; + +const baseCfdAddressUpdate = baseMessageSchema.extend({ + id: z.string(), + T: z.literal(MessageType.CFD_ADDRESS_UPDATE), + S: z.string(), // subscription + uc: z.array(z.enum(['b', 'o'])), // update content +}); + +const updateMessageSchema = baseCfdAddressUpdate.extend({ + k: z.literal('u'), // kind of message: "u" - updates + uc: z.array(z.enum(['b', 'o'])), // update content: "o" - orders updates, "b" - balance updates + b: cfdBalancesSchema.optional(), + o: z.tuple([fullOrderSchema.or(orderUpdateSchema)]).optional(), +}); + +const initialMessageSchema = baseCfdAddressUpdate.extend({ + k: z.literal('i'), // kind of message: "i" - initial + b: cfdBalancesSchema, + o: z.array(fullOrderSchema) + .optional(), // When no orders — no field +}); + +const cfdAddressUpdateSchema = z.union([ + initialMessageSchema, + updateMessageSchema, +]); + +export default cfdAddressUpdateSchema diff --git a/src/services/OrionAggregator/ws/schemas/cfdBalancesSchema.ts b/src/services/OrionAggregator/ws/schemas/cfdBalancesSchema.ts new file mode 100644 index 0000000..8a49ad3 --- /dev/null +++ b/src/services/OrionAggregator/ws/schemas/cfdBalancesSchema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +const cfdBalanceSchema = z.object({ + i: z.string(), + b: z.number(), + p: z.number(), + pp: z.number(), + fr: z.number(), + sfrl: z.number(), + lfrl: z.number(), +}) + .transform((obj) => ({ + instrument: obj.i, + balance: obj.b, + position: obj.p, + positionPrice: obj.pp, + fundingRate: obj.fr, + lastShortFundingRate: obj.sfrl, + lastLongFundingRate: obj.lfrl, +})); + +const cfdBalancesSchema = z.array(cfdBalanceSchema) + +export default cfdBalancesSchema; diff --git a/src/types.ts b/src/types.ts index 2ff19c8..84922ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,17 @@ export type Balance = { wallet: string, allowance: string, } + +export type CFDBalance = { + instrument: string, + balance: number, + position: number, + positionPrice: number, + fundingRate: number, + lastShortFundingRate: number, + lastLongFundingRate: number, +} + export interface Order { senderAddress: string; // address matcherAddress: string; // address @@ -54,6 +65,25 @@ export interface Order { buySide: number; // uint8, 1=buy, 0=sell isPersonalSign: boolean; // bool } + +export interface CFDOrder { + senderAddress: string; // address + matcherAddress: string; // address + instrumentAddress: string; // address + amount: number; // uint64 + price: number; // uint64 + matcherFee: number; // uint64 + nonce: number; // uint64 + expiration: number; // uint64 + buySide: number; // uint8, 1=buy, 0=sell + isPersonalSign: boolean; // bool +} + +export interface SignedCFDOrder extends CFDOrder { + id: string; // hash of Order (it's not part of order structure in smart-contract) + signature: string; // bytes +} + export interface SignedOrder extends Order { id: string; // hash of Order (it's not part of order structure in smart-contract) signature: string; // bytes