client.ts 11 KB

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