sui.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. import {
  2. ChainPriceListener,
  3. IPricePusher,
  4. PriceInfo,
  5. PriceItem,
  6. } from "../interface";
  7. import { DurationInSeconds } from "../utils";
  8. import { PriceServiceConnection } from "@pythnetwork/price-service-client";
  9. import { SuiPythClient } from "@pythnetwork/pyth-sui-js";
  10. import { Ed25519Keypair } from "@mysten/sui.js/keypairs/ed25519";
  11. import { TransactionBlock } from "@mysten/sui.js/transactions";
  12. import { SuiClient, SuiObjectRef, PaginatedCoins } from "@mysten/sui.js/client";
  13. const GAS_FEE_FOR_SPLIT = 2_000_000_000;
  14. // TODO: read this from on chain config
  15. const MAX_NUM_GAS_OBJECTS_IN_PTB = 256;
  16. const MAX_NUM_OBJECTS_IN_ARGUMENT = 510;
  17. type ObjectId = string;
  18. type SuiAddress = string;
  19. export class SuiPriceListener extends ChainPriceListener {
  20. private pythClient: SuiPythClient;
  21. private provider: SuiClient;
  22. constructor(
  23. pythStateId: ObjectId,
  24. wormholeStateId: ObjectId,
  25. endpoint: string,
  26. priceItems: PriceItem[],
  27. config: {
  28. pollingFrequency: DurationInSeconds;
  29. }
  30. ) {
  31. super("sui", config.pollingFrequency, priceItems);
  32. this.provider = new SuiClient({ url: endpoint });
  33. this.pythClient = new SuiPythClient(
  34. this.provider,
  35. pythStateId,
  36. wormholeStateId
  37. );
  38. }
  39. async getOnChainPriceInfo(priceId: string): Promise<PriceInfo | undefined> {
  40. try {
  41. const priceInfoObjectId = await this.pythClient.getPriceFeedObjectId(
  42. priceId
  43. );
  44. if (priceInfoObjectId === undefined) {
  45. throw new Error("Price not found on chain for price id " + priceId);
  46. }
  47. // Fetching the price info object for the above priceInfoObjectId
  48. const priceInfoObject = await this.provider.getObject({
  49. id: priceInfoObjectId,
  50. options: { showContent: true },
  51. });
  52. if (!priceInfoObject.data || !priceInfoObject.data.content)
  53. throw new Error("Price not found on chain for price id " + priceId);
  54. if (priceInfoObject.data.content.dataType !== "moveObject")
  55. throw new Error("fetched object datatype should be moveObject");
  56. const priceInfo =
  57. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  58. // @ts-ignore
  59. priceInfoObject.data.content.fields.price_info.fields.price_feed.fields
  60. .price.fields;
  61. const { magnitude, negative } = priceInfo.price.fields;
  62. const conf = priceInfo.conf;
  63. const timestamp = priceInfo.timestamp;
  64. return {
  65. price: negative ? "-" + magnitude : magnitude,
  66. conf,
  67. publishTime: Number(timestamp),
  68. };
  69. } catch (e) {
  70. console.error(`Polling Sui on-chain price for ${priceId} failed. Error:`);
  71. console.error(e);
  72. return undefined;
  73. }
  74. }
  75. }
  76. /**
  77. * The `SuiPricePusher` is designed for high-throughput of price updates.
  78. * Achieving this property requires sacrificing some nice-to-have features of other
  79. * pusher implementations that can reduce cost when running multiple pushers. It also requires
  80. * jumping through some Sui-specific hoops in order to maximize parallelism.
  81. *
  82. * The two main design features are:
  83. * 1. This implementation does not use `update_price_feeds_if_necssary` and simulate the transaction
  84. * before submission. If multiple instances of this pusher are running in parallel, all of them will
  85. * land all of their pushed updates on-chain.
  86. * 2. The pusher will split the Coin balance in the provided account into a pool of different Coin objects.
  87. * Each transaction will be allocated a Coin object from this pool as needed. This process enables the
  88. * transactions to avoid referencing the same owned objects, which allows them to be processed in parallel.
  89. */
  90. export class SuiPricePusher implements IPricePusher {
  91. constructor(
  92. private readonly signer: Ed25519Keypair,
  93. private readonly provider: SuiClient,
  94. private priceServiceConnection: PriceServiceConnection,
  95. private pythPackageId: string,
  96. private pythStateId: string,
  97. private wormholePackageId: string,
  98. private wormholeStateId: string,
  99. endpoint: string,
  100. keypair: Ed25519Keypair,
  101. private gasBudget: number,
  102. private gasPool: SuiObjectRef[],
  103. private pythClient: SuiPythClient
  104. ) {}
  105. /**
  106. * getPackageId returns the latest package id that the object belongs to. Use this to
  107. * fetch the latest package id for a given object id and handle package upgrades automatically.
  108. * @param provider
  109. * @param objectId
  110. * @returns package id
  111. */
  112. static async getPackageId(
  113. provider: SuiClient,
  114. objectId: ObjectId
  115. ): Promise<ObjectId> {
  116. const state = await provider
  117. .getObject({
  118. id: objectId,
  119. options: {
  120. showContent: true,
  121. },
  122. })
  123. .then((result) => {
  124. if (result.data?.content?.dataType == "moveObject") {
  125. return result.data.content.fields;
  126. }
  127. throw new Error("not move object");
  128. });
  129. if ("upgrade_cap" in state) {
  130. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  131. // @ts-ignore
  132. return state.upgrade_cap.fields.package;
  133. }
  134. throw new Error("upgrade_cap not found");
  135. }
  136. /**
  137. * Create a price pusher with a pool of `numGasObjects` gas coins that will be used to send transactions.
  138. * The gas coins of the wallet for the provided keypair will be merged and then evenly split into `numGasObjects`.
  139. */
  140. static async createWithAutomaticGasPool(
  141. priceServiceConnection: PriceServiceConnection,
  142. pythStateId: string,
  143. wormholeStateId: string,
  144. endpoint: string,
  145. keypair: Ed25519Keypair,
  146. gasBudget: number,
  147. numGasObjects: number
  148. ): Promise<SuiPricePusher> {
  149. if (numGasObjects > MAX_NUM_OBJECTS_IN_ARGUMENT) {
  150. throw new Error(
  151. `numGasObjects cannot be greater than ${MAX_NUM_OBJECTS_IN_ARGUMENT} until we implement split chunking`
  152. );
  153. }
  154. const provider = new SuiClient({ url: endpoint });
  155. const pythPackageId = await SuiPricePusher.getPackageId(
  156. provider,
  157. pythStateId
  158. );
  159. const wormholePackageId = await SuiPricePusher.getPackageId(
  160. provider,
  161. wormholeStateId
  162. );
  163. const gasPool = await SuiPricePusher.initializeGasPool(
  164. keypair,
  165. provider,
  166. numGasObjects
  167. );
  168. const pythClient = new SuiPythClient(
  169. provider,
  170. pythStateId,
  171. wormholeStateId
  172. );
  173. return new SuiPricePusher(
  174. keypair,
  175. provider,
  176. priceServiceConnection,
  177. pythPackageId,
  178. pythStateId,
  179. wormholePackageId,
  180. wormholeStateId,
  181. endpoint,
  182. keypair,
  183. gasBudget,
  184. gasPool,
  185. pythClient
  186. );
  187. }
  188. async updatePriceFeed(
  189. priceIds: string[],
  190. pubTimesToPush: number[]
  191. ): Promise<void> {
  192. if (priceIds.length === 0) {
  193. return;
  194. }
  195. if (priceIds.length !== pubTimesToPush.length)
  196. throw new Error("Invalid arguments");
  197. if (this.gasPool.length === 0) {
  198. console.warn("Skipping update: no available gas coin.");
  199. return;
  200. }
  201. // 3 price feeds per transaction is the optimal number for gas cost.
  202. const priceIdChunks = chunkArray(priceIds, 3);
  203. const txBlocks: TransactionBlock[] = [];
  204. await Promise.all(
  205. priceIdChunks.map(async (priceIdChunk) => {
  206. const vaas = await this.priceServiceConnection.getLatestVaas(
  207. priceIdChunk
  208. );
  209. if (vaas.length !== 1) {
  210. throw new Error(
  211. `Expected a single VAA for all priceIds ${priceIdChunk} but received ${vaas.length} VAAs: ${vaas}`
  212. );
  213. }
  214. const vaa = vaas[0];
  215. const tx = new TransactionBlock();
  216. await this.pythClient.updatePriceFeeds(
  217. tx,
  218. [Buffer.from(vaa, "base64")],
  219. priceIdChunk
  220. );
  221. txBlocks.push(tx);
  222. })
  223. );
  224. await this.sendTransactionBlocks(txBlocks);
  225. }
  226. /** Send every transaction in txs in parallel, returning when all transactions have completed. */
  227. private async sendTransactionBlocks(
  228. txs: TransactionBlock[]
  229. ): Promise<void[]> {
  230. return Promise.all(txs.map((tx) => this.sendTransactionBlock(tx)));
  231. }
  232. /** Send a single transaction block using a gas coin from the pool. */
  233. private async sendTransactionBlock(tx: TransactionBlock): Promise<void> {
  234. const gasObject = this.gasPool.shift();
  235. if (gasObject === undefined) {
  236. console.warn("No available gas coin. Skipping push.");
  237. return;
  238. }
  239. let nextGasObject: SuiObjectRef | undefined = undefined;
  240. try {
  241. tx.setGasPayment([gasObject]);
  242. tx.setGasBudget(this.gasBudget);
  243. const result = await this.provider.signAndExecuteTransactionBlock({
  244. signer: this.signer,
  245. transactionBlock: tx,
  246. options: {
  247. showEffects: true,
  248. },
  249. });
  250. nextGasObject = result.effects?.mutated
  251. ?.map((obj) => obj.reference)
  252. .find((ref) => ref.objectId === gasObject.objectId);
  253. console.log(
  254. "Successfully updated price with transaction digest ",
  255. result.digest
  256. );
  257. } catch (e: any) {
  258. console.log("Error when signAndExecuteTransactionBlock");
  259. if (
  260. String(e).includes("Balance of gas object") ||
  261. String(e).includes("GasBalanceTooLow")
  262. ) {
  263. // If the error is caused by insufficient gas, we should panic
  264. throw e;
  265. } else {
  266. // Refresh the coin object here in case the error is caused by an object version mismatch.
  267. nextGasObject = await SuiPricePusher.tryRefreshObjectReference(
  268. this.provider,
  269. gasObject
  270. );
  271. }
  272. console.error(e);
  273. if ("data" in e) {
  274. console.error("Error has .data field:");
  275. console.error(JSON.stringify(e.data));
  276. }
  277. }
  278. if (nextGasObject !== undefined) {
  279. this.gasPool.push(nextGasObject);
  280. }
  281. }
  282. // This function will smash all coins owned by the signer into one, and then
  283. // split them equally into numGasObjects.
  284. private static async initializeGasPool(
  285. signer: Ed25519Keypair,
  286. provider: SuiClient,
  287. numGasObjects: number
  288. ): Promise<SuiObjectRef[]> {
  289. const signerAddress = await signer.toSuiAddress();
  290. const consolidatedCoin = await SuiPricePusher.mergeGasCoinsIntoOne(
  291. signer,
  292. provider,
  293. signerAddress
  294. );
  295. const coinResult = await provider.getObject({
  296. id: consolidatedCoin.objectId,
  297. options: { showContent: true },
  298. });
  299. let balance;
  300. if (
  301. coinResult.data &&
  302. coinResult.data.content &&
  303. coinResult.data.content.dataType == "moveObject"
  304. ) {
  305. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  306. // @ts-ignore
  307. balance = coinResult.data.content.fields.balance;
  308. } else throw new Error("Bad coin object");
  309. const splitAmount =
  310. (BigInt(balance) - BigInt(GAS_FEE_FOR_SPLIT)) / BigInt(numGasObjects);
  311. const gasPool = await SuiPricePusher.splitGasCoinEqually(
  312. signer,
  313. provider,
  314. signerAddress,
  315. Number(splitAmount),
  316. numGasObjects,
  317. consolidatedCoin
  318. );
  319. console.log("Gas pool is filled with coins: ", gasPool);
  320. return gasPool;
  321. }
  322. // Attempt to refresh the version of the provided object reference to point to the current version
  323. // of the object. Return the provided object reference if an error occurs or the object could not
  324. // be retrieved.
  325. private static async tryRefreshObjectReference(
  326. provider: SuiClient,
  327. ref: SuiObjectRef
  328. ): Promise<SuiObjectRef> {
  329. try {
  330. const objectResponse = await provider.getObject({ id: ref.objectId });
  331. if (objectResponse.data !== undefined) {
  332. return {
  333. digest: objectResponse.data!.digest,
  334. objectId: objectResponse.data!.objectId,
  335. version: objectResponse.data!.version,
  336. };
  337. } else {
  338. return ref;
  339. }
  340. } catch (error) {
  341. return ref;
  342. }
  343. }
  344. private static async getAllGasCoins(
  345. provider: SuiClient,
  346. owner: SuiAddress
  347. ): Promise<SuiObjectRef[]> {
  348. let hasNextPage = true;
  349. let cursor;
  350. const coins = new Set<string>([]);
  351. let numCoins = 0;
  352. while (hasNextPage) {
  353. const paginatedCoins: PaginatedCoins = await provider.getCoins({
  354. owner,
  355. cursor,
  356. });
  357. numCoins += paginatedCoins.data.length;
  358. paginatedCoins.data.forEach((c) =>
  359. coins.add(
  360. JSON.stringify({
  361. objectId: c.coinObjectId,
  362. version: c.version,
  363. digest: c.digest,
  364. })
  365. )
  366. );
  367. hasNextPage = paginatedCoins.hasNextPage;
  368. cursor = paginatedCoins.nextCursor;
  369. }
  370. if (numCoins !== coins.size) {
  371. throw new Error("Unexpected getCoins result: duplicate coins found");
  372. }
  373. return [...coins].map((item) => JSON.parse(item));
  374. }
  375. private static async splitGasCoinEqually(
  376. signer: Ed25519Keypair,
  377. provider: SuiClient,
  378. signerAddress: SuiAddress,
  379. splitAmount: number,
  380. numGasObjects: number,
  381. gasCoin: SuiObjectRef
  382. ): Promise<SuiObjectRef[]> {
  383. // TODO: implement chunking if numGasObjects exceeds MAX_NUM_CREATED_OBJECTS
  384. const tx = new TransactionBlock();
  385. const coins = tx.splitCoins(
  386. tx.gas,
  387. Array.from({ length: numGasObjects }, () => tx.pure(splitAmount))
  388. );
  389. tx.transferObjects(
  390. Array.from({ length: numGasObjects }, (_, i) => coins[i]),
  391. tx.pure(signerAddress)
  392. );
  393. tx.setGasPayment([gasCoin]);
  394. const result = await provider.signAndExecuteTransactionBlock({
  395. signer,
  396. transactionBlock: tx,
  397. options: { showEffects: true },
  398. });
  399. const error = result?.effects?.status.error;
  400. if (error) {
  401. throw new Error(
  402. `Failed to initialize gas pool: ${error}. Try re-running the script`
  403. );
  404. }
  405. const newCoins = result.effects!.created!.map((obj) => obj.reference);
  406. if (newCoins.length !== numGasObjects) {
  407. throw new Error(
  408. `Failed to initialize gas pool. Expected ${numGasObjects}, got: ${newCoins}`
  409. );
  410. }
  411. return newCoins;
  412. }
  413. private static async mergeGasCoinsIntoOne(
  414. signer: Ed25519Keypair,
  415. provider: SuiClient,
  416. owner: SuiAddress
  417. ): Promise<SuiObjectRef> {
  418. const gasCoins = await SuiPricePusher.getAllGasCoins(provider, owner);
  419. // skip merging if there is only one coin
  420. if (gasCoins.length === 1) {
  421. return gasCoins[0];
  422. }
  423. const gasCoinsChunks = chunkArray<SuiObjectRef>(
  424. gasCoins,
  425. MAX_NUM_GAS_OBJECTS_IN_PTB - 2
  426. );
  427. let finalCoin;
  428. const lockedAddresses: Set<string> = new Set();
  429. for (let i = 0; i < gasCoinsChunks.length; i++) {
  430. const mergeTx = new TransactionBlock();
  431. let coins = gasCoinsChunks[i];
  432. coins = coins.filter((coin) => !lockedAddresses.has(coin.objectId));
  433. if (finalCoin) {
  434. coins = [finalCoin, ...coins];
  435. }
  436. mergeTx.setGasPayment(coins);
  437. let mergeResult;
  438. try {
  439. mergeResult = await provider.signAndExecuteTransactionBlock({
  440. signer,
  441. transactionBlock: mergeTx,
  442. options: { showEffects: true },
  443. });
  444. } catch (e) {
  445. if (
  446. String(e).includes(
  447. "quorum of validators because of locked objects. Retried a conflicting transaction"
  448. )
  449. ) {
  450. Object.values((e as any).data).forEach((lockedObjects: any) => {
  451. lockedObjects.forEach((lockedObject: [string, number, string]) => {
  452. lockedAddresses.add(lockedObject[0]);
  453. });
  454. });
  455. // retry merging without the locked coins
  456. i--;
  457. continue;
  458. }
  459. throw e;
  460. }
  461. const error = mergeResult?.effects?.status.error;
  462. if (error) {
  463. throw new Error(
  464. `Failed to merge coins when initializing gas pool: ${error}. Try re-running the script`
  465. );
  466. }
  467. finalCoin = mergeResult.effects!.mutated!.map((obj) => obj.reference)[0];
  468. }
  469. return finalCoin as SuiObjectRef;
  470. }
  471. }
  472. function chunkArray<T>(array: Array<T>, size: number): Array<Array<T>> {
  473. const chunked = [];
  474. let index = 0;
  475. while (index < array.length) {
  476. chunked.push(array.slice(index, size + index));
  477. index += size;
  478. }
  479. return chunked;
  480. }