Skip to content

Commit

Permalink
cloud-functions: Added getSuiEvents cloud function
Browse files Browse the repository at this point in the history
  • Loading branch information
kev1n-peters authored and evan-gray committed Mar 20, 2024
1 parent 4b38368 commit 24e9ffb
Show file tree
Hide file tree
Showing 8 changed files with 580 additions and 85 deletions.
6 changes: 5 additions & 1 deletion cloud_functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"build": "tsc",
"dev": "ts-node src/index.ts",
"start": "npx functions-framework --target=getSolanaEvents [--signature-type=http]",
"start": "npx functions-framework --target=getSuiEvents [--signature-type=http]",
"deploy": "bash scripts/deploy.sh",
"gcp-build": "npm i ./dist/src/wormhole-foundation-wormhole-monitor-common-0.0.1.tgz ./dist/src/wormhole-foundation-wormhole-monitor-database-0.0.1.tgz"
},
Expand All @@ -18,6 +18,7 @@
"@google-cloud/functions-framework": "^3.1.3",
"@google-cloud/pubsub": "^3.4.1",
"@google-cloud/storage": "^6.8.0",
"@mysten/sui.js": "^0.45.0",
"@solana/web3.js": "^1.87.3",
"axios": "^1.5.0",
"borsh": "^1.0.0",
Expand All @@ -26,5 +27,8 @@
"knex": "^2.4.2",
"path-to-regexp": "^6.2.1",
"pg": "^8.10.0"
},
"devDependencies": {
"typescript": "^5.2.2"
}
}
1 change: 1 addition & 0 deletions cloud_functions/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ gcloud functions --project "$GCP_PROJECT" deploy refresh-todays-token-prices --e
gcloud functions --project "$GCP_PROJECT" deploy update-token-metadata --entry-point updateTokenMetadata --runtime nodejs18 --trigger-http --no-allow-unauthenticated --timeout 300 --memory 256MB --region europe-west3 --set-env-vars PG_USER=$PG_USER,PG_PASSWORD=$PG_PASSWORD,PG_DATABASE=$PG_DATABASE,PG_HOST=$PG_HOST,PG_TOKEN_METADATA_TABLE=$PG_TOKEN_METADATA_TABLE
gcloud functions --project "$GCP_PROJECT" deploy wormchain-monitor --entry-point wormchainMonitor --runtime nodejs18 --trigger-http --no-allow-unauthenticated --timeout 300 --memory 256MB --region europe-west3 --set-env-vars WORMCHAIN_SLACK_CHANNEL_ID=$WORMCHAIN_SLACK_CHANNEL_ID,WORMCHAIN_SLACK_POST_URL=$WORMCHAIN_SLACK_POST_URL,WORMCHAIN_SLACK_BOT_TOKEN=$WORMCHAIN_SLACK_BOT_TOKEN,WORMCHAIN_PAGERDUTY_ROUTING_KEY=$WORMCHAIN_PAGERDUTY_ROUTING_KEY,WORMCHAIN_PAGERDUTY_URL=$WORMCHAIN_PAGERDUTY_URL
gcloud functions --project "$GCP_PROJECT" deploy get-solana-events --entry-point getSolanaEvents --runtime nodejs18 --trigger-http --allow-unauthenticated --timeout 300 --memory 256MB --region europe-west3 --set-env-vars SOLANA_RPC=$SOLANA_RPC
gcloud functions --project "$GCP_PROJECT" deploy get-sui-events --entry-point getSuiEvents --runtime nodejs18 --trigger-http --allow-unauthenticated --timeout 300 --memory 256MB --region europe-west3

if [ "$NETWORK" == "MAINNET" ]; then
echo "Finished deploying MAINNET functions"
Expand Down
11 changes: 1 addition & 10 deletions cloud_functions/src/getSolanaEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,7 @@ import * as ethers from 'ethers';
import * as bs58 from 'bs58';
import { deserialize } from 'borsh';
import { assertEnvironmentVariable } from '@wormhole-foundation/wormhole-monitor-common';

interface EventData {
blockNumber: number;
txHash: string;
from: string;
to: string;
token: string;
amount: string;
isDeposit: boolean;
}
import { EventData } from './types';

export async function getSolanaEvents(req: any, res: any) {
res.set('Access-Control-Allow-Origin', '*');
Expand Down
279 changes: 279 additions & 0 deletions cloud_functions/src/getSuiEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { ethers } from 'ethers';
import {
SuiClient,
SuiEvent,
SuiObjectChange,
SuiTransactionBlockResponse,
getFullnodeUrl,
PaginatedTransactionResponse,
} from '@mysten/sui.js/client';
import { normalizeSuiAddress, SUI_TYPE_ARG } from '@mysten/sui.js/utils';
import { EventData } from './types';

const wormholeMessageEventType =
'0x5306f64e312b581766351c07af79c72fcb1cd25147157fdc2f8ad76de9a3fb6a::publish_message::WormholeMessage';
const tokenBridgeAddress = '0xc57508ee0d4595e5a8728974a4a93a787d38f339757230d441e895422c07aba9';
const originalTokenBridgePackageId =
'0x26efee2b51c911237888e5dc6702868abca3c7ac12c53f76ef8eba0697695e3d';

export async function getSuiEvents(req: any, res: any) {
res.set('Access-Control-Allow-Origin', '*');
if (req.method === 'OPTIONS') {
// Send response to OPTIONS requests
res.set('Access-Control-Allow-Methods', 'GET');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Access-Control-Max-Age', '3600');
res.sendStatus(204);
return;
}
if (!req.query.fromCheckpoint) {
res.status(400).send('fromCheckpoint is required');
return;
}
if (!req.query.toCheckpoint) {
res.status(400).send('toCheckpoint is required');
return;
}
try {
const fromCheckpoint = Number(req.query.fromCheckpoint);
const toCheckpoint = Number(req.query.toCheckpoint);
console.log(`fetching events from ${fromCheckpoint} to ${toCheckpoint}`);
const events = await _getSuiEvents(fromCheckpoint, toCheckpoint);
console.log(`fetched ${events.length} events`);
res.json(events);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
}

/**
* Retrieves Sui events from a given checkpoint range using the token bridge.
* Optimized to make as few RPC calls as possible.
* @param fromCheckpoint The starting checkpoint to retrieve events from.
* @param toCheckpoint The ending checkpoint to retrieve events from.
* @returns An array of EventData objects representing the events that occurred within the given checkpoint range.
*/
const _getSuiEvents = async (
fromCheckpoint: number,
toCheckpoint: number
): Promise<EventData[]> => {
const events: EventData[] = [];
const txBlocks = await getTransactionBlocks(fromCheckpoint, toCheckpoint, tokenBridgeAddress);
for (const txBlock of txBlocks) {
if (
txBlock.effects?.status.status !== 'success' ||
!txBlock.checkpoint ||
!txBlock.objectChanges ||
txBlock.transaction?.data.transaction.kind !== 'ProgrammableTransaction'
) {
continue;
}
const transactions = txBlock.transaction.data.transaction.transactions;
for (const tx of transactions) {
const moveCall = 'MoveCall' in tx && tx.MoveCall;
if (!moveCall || moveCall.package !== originalTokenBridgePackageId) {
continue;
}
if (
(moveCall.module === 'complete_transfer_with_payload' &&
moveCall.function === 'authorize_transfer') ||
(moveCall.module === 'complete_transfer' && moveCall.function === 'authorize_transfer')
) {
const token = moveCall.type_arguments![0];
// search backwards for the parse_and_verify call
const parseAndVerifyTx = transactions
.slice(
0,
transactions.findIndex((value) => value === tx)
)
.reverse()
.find(
(tx) =>
'MoveCall' in tx &&
tx.MoveCall.module === 'vaa' &&
tx.MoveCall.function === 'parse_and_verify'
);
if (!parseAndVerifyTx || !('MoveCall' in parseAndVerifyTx)) {
continue;
}
const vaaArg = parseAndVerifyTx.MoveCall.arguments?.[1];
if (!vaaArg || typeof vaaArg !== 'object' || !('Input' in vaaArg)) {
continue;
}
const vaaInput = txBlock.transaction.data.transaction.inputs[vaaArg.Input];
if (!vaaInput || vaaInput.type !== 'pure' || vaaInput.valueType !== 'vector<u8>') {
continue;
}
const vaa = Buffer.from(vaaInput.value as number[]);
const sigStart = 6;
const numSigners = vaa[5];
const sigLength = 66;
const body = vaa.subarray(sigStart + sigLength * numSigners);
const payload = body.subarray(51);
const type = payload.readUInt8(0);
if (type !== 1 && type !== 3) {
continue;
}
const amount = await denormalizeAmount(
token,
ethers.BigNumber.from(payload.subarray(1, 33))
);
const to = `0x${payload.subarray(67, 99).toString('hex')}`;
const event: EventData = {
blockNumber: Number(txBlock.checkpoint),
txHash: txBlock.digest,
// Wrapped tokens are minted from the zero address on Ethereum
// Override the from address to be the zero address for consistency
from: isWrappedToken(token, txBlock.objectChanges)
? ethers.constants.AddressZero
: tokenBridgeAddress,
to,
token,
amount: amount.toString(),
isDeposit: false,
};
events.push(event);
}
if (
((moveCall.module === 'transfer_tokens_with_payload' &&
moveCall.function === 'transfer_tokens_with_payload') ||
(moveCall.module === 'transfer_tokens' && moveCall.function === 'transfer_tokens')) &&
txBlock.events
) {
const token = tx.MoveCall.type_arguments![0];
const payload = getWormholeMessagePayload(txBlock.events);
const originChain = payload.readUint16BE(65);
const toChain = payload.readUInt16BE(99);
const amount = await denormalizeAmount(
token,
ethers.BigNumber.from(payload.subarray(1, 33))
);
const isWrapped = isWrappedToken(token, txBlock.objectChanges);
const event: EventData = {
blockNumber: Number(txBlock.checkpoint),
txHash: txBlock.digest,
from: txBlock.transaction.data.sender,
// if this is a wrapped token being burned and not being sent to its origin chain,
// then it should be included in the volume by fixing the to address
to:
!isWrapped || originChain !== toChain
? tokenBridgeAddress
: ethers.constants.AddressZero,
token,
amount: amount.toString(),
isDeposit: !isWrapped,
};
events.push(event);
}
}
}
return events;
};

const getWormholeMessagePayload = (events: SuiEvent[]): Buffer => {
const filtered = events.filter((event) => {
return event.type === wormholeMessageEventType;
});
// TODO: support multiple transfers in a single txBlock
if (filtered.length !== 1) {
throw new Error(`Expected exactly one wormhole message event, found ${filtered.length}`);
}
return Buffer.from((filtered[0].parsedJson as any).payload);
};

const tokenDecimalsCache: { [token: string]: number } = {};

const getTokenDecimals = async (token: string): Promise<number> => {
if (token in tokenDecimalsCache) {
return tokenDecimalsCache[token];
}
const client = getClient();
const coinMetadata = await client.getCoinMetadata({ coinType: token });
if (coinMetadata === null) {
throw new Error(`Failed to get coin metadata for ${token}`);
}
const { decimals } = coinMetadata;
tokenDecimalsCache[token] = decimals;
return decimals;
};

const denormalizeAmount = async (
token: string,
amount: ethers.BigNumber
): Promise<ethers.BigNumber> => {
const decimals = await getTokenDecimals(token);
if (decimals > 8) {
return amount.mul(ethers.BigNumber.from(10).pow(decimals - 8));
}
return amount;
};

const isWrappedToken = (token: string, objectChanges: SuiObjectChange[]) => {
const split = token.split('::');
if (split.length !== 3) {
throw new Error(`Invalid token ${token}`);
}
const normalized =
token === SUI_TYPE_ARG ? token : `${normalizeSuiAddress(split[0])}::${split[1]}::${split[2]}`;
const nativeKey = `0x2::dynamic_field::Field<${originalTokenBridgePackageId}::token_registry::Key<${normalized}>, ${originalTokenBridgePackageId}::native_asset::NativeAsset<${normalized}>>`;
const wrappedKey = `0x2::dynamic_field::Field<${originalTokenBridgePackageId}::token_registry::Key<${normalized}>, ${originalTokenBridgePackageId}::wrapped_asset::WrappedAsset<${normalized}>>`;
const value = objectChanges.find(
(change) => change.type === 'mutated' && [nativeKey, wrappedKey].includes(change.objectType)
);
if (!value) {
throw new Error(`Failed to find object change for token ${normalized}`);
}
return value.type === 'mutated' && value.objectType === wrappedKey;
};

export const getTransactionBlocks = async (
fromCheckpoint: number,
toCheckpoint: number,
changedObject: string
): Promise<SuiTransactionBlockResponse[]> => {
const client = getClient();
const results: SuiTransactionBlockResponse[] = [];
let hasNextPage = false;
let cursor: string | null | undefined = undefined;
let oldestCheckpoint: string | null = null;
do {
// TODO: The public RPC doesn't support fetching events by chaining filters with a `TimeRange` filter,
// so we have to search backwards for our checkpoint range
const response: PaginatedTransactionResponse = await client.queryTransactionBlocks({
filter: { ChangedObject: changedObject },
cursor,
options: {
showEffects: true,
showEvents: true,
showInput: true,
showObjectChanges: true,
},
});
for (const txBlock of response.data) {
const checkpoint = txBlock.checkpoint;
if (!checkpoint) {
continue;
}
if (checkpoint >= fromCheckpoint.toString() && checkpoint <= toCheckpoint.toString()) {
results.push(txBlock);
}
if (oldestCheckpoint === null || checkpoint < oldestCheckpoint) {
oldestCheckpoint = checkpoint;
}
}
hasNextPage = response.hasNextPage;
cursor = response.nextCursor;
} while (
hasNextPage &&
cursor &&
oldestCheckpoint &&
oldestCheckpoint >= fromCheckpoint.toString()
);
return results;
};

const getClient = () => {
const url = process.env.SUI_RPC ?? getFullnodeUrl('mainnet');
return new SuiClient({ url });
};
2 changes: 2 additions & 0 deletions cloud_functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const { getReobserveVaas } = require('./getReobserveVaas');
export const { wormchainMonitor } = require('./wormchainMonitor');
export const { getLatestTokenData } = require('./getLatestTokenData');
export const { getSolanaEvents } = require('./getSolanaEvents');
export const { getSuiEvents } = require('./getSuiEvents');

// Register an HTTP function with the Functions Framework that will be executed
// when you make an HTTP request to the deployed function's endpoint.
Expand All @@ -51,3 +52,4 @@ functions.http('getReobserveVaas', getReobserveVaas);
functions.http('wormchainMonitor', wormchainMonitor);
functions.http('latestTokenData', getLatestTokenData);
functions.http('getSolanaEvents', getSolanaEvents);
functions.http('getSuiEvents', getSuiEvents);
10 changes: 10 additions & 0 deletions cloud_functions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,13 @@ export type SlackInfo = {
msg: string;
bannerTxt: string;
};

export interface EventData {
blockNumber: number;
txHash: string;
from: string;
to: string;
token: string;
amount: string;
isDeposit: boolean;
}
3 changes: 2 additions & 1 deletion cloud_functions/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"extends": "../tsconfig.base.json",
"references": [{ "path": "../common" }, { "path": "../database" }],
"compilerOptions": {
"outDir": "dist"
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src", "src/data/*.json"]
}
Loading

0 comments on commit 24e9ffb

Please sign in to comment.