client.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import {
  2. JsonRpcProvider,
  3. ObjectId,
  4. SUI_CLOCK_OBJECT_ID,
  5. TransactionBlock,
  6. } from "@mysten/sui.js";
  7. import { HexString } from "@pythnetwork/price-service-client";
  8. export class SuiPythClient {
  9. private pythPackageId: ObjectId | undefined;
  10. private wormholePackageId: ObjectId | undefined;
  11. private priceTableId: ObjectId | undefined;
  12. private priceFeedObjectIdCache: Map<HexString, ObjectId> = new Map();
  13. private baseUpdateFee: number | undefined;
  14. constructor(
  15. public provider: JsonRpcProvider,
  16. public pythStateId: ObjectId,
  17. public wormholeStateId: ObjectId
  18. ) {
  19. this.pythPackageId = undefined;
  20. this.wormholePackageId = undefined;
  21. }
  22. async getBaseUpdateFee(): Promise<number> {
  23. if (this.baseUpdateFee === undefined) {
  24. const result = await this.provider.getObject({
  25. id: this.pythStateId,
  26. options: { showContent: true },
  27. });
  28. if (
  29. !result.data ||
  30. !result.data.content ||
  31. result.data.content.dataType !== "moveObject"
  32. )
  33. throw new Error("Unable to fetch pyth state object");
  34. this.baseUpdateFee = result.data.content.fields.base_update_fee as number;
  35. }
  36. return this.baseUpdateFee;
  37. }
  38. /**
  39. * getPackageId returns the latest package id that the object belongs to. Use this to
  40. * fetch the latest package id for a given object id and handle package upgrades automatically.
  41. * @param objectId
  42. * @returns package id
  43. */
  44. async getPackageId(objectId: ObjectId): Promise<ObjectId> {
  45. const state = await this.provider
  46. .getObject({
  47. id: objectId,
  48. options: {
  49. showContent: true,
  50. },
  51. })
  52. .then((result) => {
  53. if (result.data?.content?.dataType == "moveObject") {
  54. return result.data.content.fields;
  55. }
  56. throw new Error("not move object");
  57. });
  58. if ("upgrade_cap" in state) {
  59. return state.upgrade_cap.fields.package;
  60. }
  61. throw new Error("upgrade_cap not found");
  62. }
  63. /**
  64. * Adds the commands for calling wormhole and verifying the vaas and returns the verified vaas.
  65. * @param vaas array of vaas to verify
  66. * @param tx transaction block to add commands to
  67. */
  68. async verifyVaas(vaas: Buffer[], tx: TransactionBlock) {
  69. const wormholePackageId = await this.getWormholePackageId();
  70. const verifiedVaas = [];
  71. for (const vaa of vaas) {
  72. const [verifiedVaa] = tx.moveCall({
  73. target: `${wormholePackageId}::vaa::parse_and_verify`,
  74. arguments: [
  75. tx.object(this.wormholeStateId),
  76. tx.pure(Array.from(vaa)),
  77. tx.object(SUI_CLOCK_OBJECT_ID),
  78. ],
  79. });
  80. verifiedVaas.push(verifiedVaa);
  81. }
  82. return verifiedVaas;
  83. }
  84. /**
  85. * Adds the necessary commands for updating the pyth price feeds to the transaction block.
  86. * @param tx transaction block to add commands to
  87. * @param updates array of price feed updates received from the price service
  88. * @param feedIds array of feed ids to update (in hex format)
  89. */
  90. async updatePriceFeeds(
  91. tx: TransactionBlock,
  92. updates: Buffer[],
  93. feedIds: HexString[]
  94. ): Promise<ObjectId[]> {
  95. const wormholePackageId = await this.getWormholePackageId();
  96. const packageId = await this.getPythPackageId();
  97. let priceUpdatesHotPotato;
  98. if (updates.every((update) => this.isAccumulatorMsg(update))) {
  99. if (updates.length > 1) {
  100. throw new Error(
  101. "SDK does not support sending multiple accumulator messages in a single transaction"
  102. );
  103. }
  104. const vaa = this.extractVaaBytesFromAccumulatorMessage(updates[0]);
  105. const verifiedVaas = await this.verifyVaas([vaa], tx);
  106. [priceUpdatesHotPotato] = tx.moveCall({
  107. target: `${packageId}::pyth::create_authenticated_price_infos_using_accumulator`,
  108. arguments: [
  109. tx.object(this.pythStateId),
  110. tx.pure(Array.from(updates[0])),
  111. verifiedVaas[0],
  112. tx.object(SUI_CLOCK_OBJECT_ID),
  113. ],
  114. });
  115. } else if (updates.every((vaa) => !this.isAccumulatorMsg(vaa))) {
  116. const verifiedVaas = await this.verifyVaas(updates, tx);
  117. [priceUpdatesHotPotato] = tx.moveCall({
  118. target: `${packageId}::pyth::create_price_infos_hot_potato`,
  119. arguments: [
  120. tx.object(this.pythStateId),
  121. tx.makeMoveVec({
  122. type: `${wormholePackageId}::vaa::VAA`,
  123. objects: verifiedVaas,
  124. }),
  125. tx.object(SUI_CLOCK_OBJECT_ID),
  126. ],
  127. });
  128. } else {
  129. throw new Error("Can't mix accumulator and non-accumulator messages");
  130. }
  131. const priceInfoObjects: ObjectId[] = [];
  132. for (const feedId of feedIds) {
  133. const priceInfoObjectId = await this.getPriceFeedObjectId(feedId);
  134. if (!priceInfoObjectId) {
  135. throw new Error(
  136. `Price feed ${feedId} not found, please create it first`
  137. );
  138. }
  139. priceInfoObjects.push(priceInfoObjectId);
  140. const coin = tx.splitCoins(tx.gas, [
  141. tx.pure(await this.getBaseUpdateFee()),
  142. ]);
  143. [priceUpdatesHotPotato] = tx.moveCall({
  144. target: `${packageId}::pyth::update_single_price_feed`,
  145. arguments: [
  146. tx.object(this.pythStateId),
  147. priceUpdatesHotPotato,
  148. tx.object(priceInfoObjectId),
  149. coin,
  150. tx.object(SUI_CLOCK_OBJECT_ID),
  151. ],
  152. });
  153. }
  154. tx.moveCall({
  155. target: `${packageId}::hot_potato_vector::destroy`,
  156. arguments: [priceUpdatesHotPotato],
  157. typeArguments: [`${packageId}::price_info::PriceInfo`],
  158. });
  159. return priceInfoObjects;
  160. }
  161. async createPriceFeed(tx: TransactionBlock, updates: Buffer[]) {
  162. const wormholePackageId = await this.getWormholePackageId();
  163. const packageId = await this.getPythPackageId();
  164. if (updates.every((update) => this.isAccumulatorMsg(update))) {
  165. if (updates.length > 1) {
  166. throw new Error(
  167. "SDK does not support sending multiple accumulator messages in a single transaction"
  168. );
  169. }
  170. const vaa = this.extractVaaBytesFromAccumulatorMessage(updates[0]);
  171. const verifiedVaas = await this.verifyVaas([vaa], tx);
  172. tx.moveCall({
  173. target: `${packageId}::pyth::create_price_feeds_using_accumulator`,
  174. arguments: [
  175. tx.object(this.pythStateId),
  176. tx.pure(Array.from(updates[0])),
  177. verifiedVaas[0],
  178. tx.object(SUI_CLOCK_OBJECT_ID),
  179. ],
  180. });
  181. } else if (updates.every((vaa) => !this.isAccumulatorMsg(vaa))) {
  182. const verifiedVaas = await this.verifyVaas(updates, tx);
  183. tx.moveCall({
  184. target: `${packageId}::pyth::create_price_feeds`,
  185. arguments: [
  186. tx.object(this.pythStateId),
  187. tx.makeMoveVec({
  188. type: `${wormholePackageId}::vaa::VAA`,
  189. objects: verifiedVaas,
  190. }),
  191. tx.object(SUI_CLOCK_OBJECT_ID),
  192. ],
  193. });
  194. } else {
  195. throw new Error("Can't mix accumulator and non-accumulator messages");
  196. }
  197. }
  198. /**
  199. * Get the packageId for the wormhole package if not already cached
  200. */
  201. async getWormholePackageId() {
  202. if (!this.wormholePackageId) {
  203. this.wormholePackageId = await this.getPackageId(this.wormholeStateId);
  204. }
  205. return this.wormholePackageId;
  206. }
  207. /**
  208. * Get the packageId for the pyth package if not already cached
  209. */
  210. async getPythPackageId() {
  211. if (!this.pythPackageId) {
  212. this.pythPackageId = await this.getPackageId(this.pythStateId);
  213. }
  214. return this.pythPackageId;
  215. }
  216. /**
  217. * Get the priceFeedObjectId for a given feedId if not already cached
  218. * @param feedId
  219. */
  220. async getPriceFeedObjectId(feedId: HexString): Promise<ObjectId | undefined> {
  221. const normalizedFeedId = feedId.replace("0x", "");
  222. if (!this.priceFeedObjectIdCache.has(normalizedFeedId)) {
  223. const tableId = await this.getPriceTableId();
  224. const result = await this.provider.getDynamicFieldObject({
  225. parentId: tableId,
  226. name: {
  227. type: `${await this.getPythPackageId()}::price_identifier::PriceIdentifier`,
  228. value: {
  229. bytes: Array.from(Buffer.from(normalizedFeedId, "hex")),
  230. },
  231. },
  232. });
  233. if (!result.data || !result.data.content) {
  234. return undefined;
  235. }
  236. if (result.data.content.dataType !== "moveObject") {
  237. throw new Error("Price feed type mismatch");
  238. }
  239. this.priceFeedObjectIdCache.set(
  240. normalizedFeedId,
  241. result.data.content.fields.value
  242. );
  243. }
  244. return this.priceFeedObjectIdCache.get(normalizedFeedId);
  245. }
  246. /**
  247. * Fetches the price table object id for the current state id if not cached
  248. * @returns price table object id
  249. */
  250. async getPriceTableId(): Promise<ObjectId> {
  251. if (this.priceTableId === undefined) {
  252. const result = await this.provider.getDynamicFieldObject({
  253. parentId: this.pythStateId,
  254. name: {
  255. type: "vector<u8>",
  256. value: "price_info",
  257. },
  258. });
  259. if (!result.data) {
  260. throw new Error(
  261. "Price Table not found, contract may not be initialized"
  262. );
  263. }
  264. this.priceTableId = result.data.objectId;
  265. }
  266. return this.priceTableId;
  267. }
  268. /**
  269. * Checks if a message is an accumulator message or not
  270. * @param msg - update message from price service
  271. */
  272. isAccumulatorMsg(msg: Buffer) {
  273. const ACCUMULATOR_MAGIC = "504e4155";
  274. return msg.toString("hex").slice(0, 8) === ACCUMULATOR_MAGIC;
  275. }
  276. /**
  277. * Obtains the vaa bytes embedded in an accumulator message.
  278. * @param accumulatorMessage - the accumulator price update message
  279. * @returns vaa bytes as a uint8 array
  280. */
  281. extractVaaBytesFromAccumulatorMessage(accumulatorMessage: Buffer): Buffer {
  282. if (!this.isAccumulatorMsg(accumulatorMessage)) {
  283. throw new Error("Not an accumulator message");
  284. }
  285. // the first 6 bytes in the accumulator message encode the header, major, and minor bytes
  286. // we ignore them, since we are only interested in the VAA bytes
  287. const trailingPayloadSize = accumulatorMessage.readUint8(6);
  288. const vaaSizeOffset =
  289. 7 + // header bytes (header(4) + major(1) + minor(1) + trailing payload size(1))
  290. trailingPayloadSize + // trailing payload (variable number of bytes)
  291. 1; // proof_type (1 byte)
  292. const vaaSize = accumulatorMessage.readUint16BE(vaaSizeOffset);
  293. const vaaOffset = vaaSizeOffset + 2;
  294. return accumulatorMessage.subarray(vaaOffset, vaaOffset + vaaSize);
  295. }
  296. }