-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
cloud-functions: Added getSuiEvents cloud function
- Loading branch information
1 parent
4b38368
commit 24e9ffb
Showing
8 changed files
with
580 additions
and
85 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.