From 20c271c6c7367858ae37ecf1779cfafb3e18e76f Mon Sep 17 00:00:00 2001 From: Alistair Singh Date: Wed, 29 May 2024 04:03:30 +0200 Subject: [PATCH 1/9] Snowbridge/API version 0.1.6 (#1211) * improve start up performance * history to ethereum working * fmt * toPolkadot tracking working * fmt * run all * remove cache * final fixes * added max consumers check * more fixes * remove logging * check max consumers * update version * remove apis not compatible with next * decode transaction detail * fmt * tracking id * added when field * fix beneficiary address * source addresses * one fetch at a time * add asset type * fmt --- web/packages/api/package.json | 2 +- web/packages/api/src/assets.ts | 11 +- web/packages/api/src/environment.ts | 21 + web/packages/api/src/history.ts | 1062 +++++++++++++++++ web/packages/api/src/index.ts | 63 +- web/packages/api/src/subscan.ts | 204 ++++ web/packages/api/src/toEthereum.ts | 16 +- web/packages/api/src/toPolkadot.ts | 60 +- web/packages/contract-types/package.json | 2 +- .../operations/src/global_transfer_history.ts | 99 ++ web/packages/operations/src/transfer_token.ts | 8 +- web/pnpm-lock.yaml | 13 +- 12 files changed, 1512 insertions(+), 49 deletions(-) create mode 100644 web/packages/api/src/history.ts create mode 100644 web/packages/api/src/subscan.ts create mode 100644 web/packages/operations/src/global_transfer_history.ts diff --git a/web/packages/api/package.json b/web/packages/api/package.json index 82e8ac1d70..9b83bf1fd2 100644 --- a/web/packages/api/package.json +++ b/web/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@snowbridge/api", - "version": "0.1.5", + "version": "0.1.6", "description": "Snowbridge API client", "license": "Apache-2.0", "repository": { diff --git a/web/packages/api/src/assets.ts b/web/packages/api/src/assets.ts index b05d175820..0ba35f71af 100644 --- a/web/packages/api/src/assets.ts +++ b/web/packages/api/src/assets.ts @@ -106,7 +106,16 @@ export const assetErc20Balance = async ( } } -export const assetErc20Metadata = async (context: Context, tokenAddress: string) => { +export type ERC20Metadata = { + name: string + symbol: string + decimals: bigint +} + +export const assetErc20Metadata = async ( + context: Context, + tokenAddress: string +): Promise => { const tokenMetadata = IERC20Metadata__factory.connect(tokenAddress, context.ethereum.api) const [name, symbol, decimals] = await Promise.all([ tokenMetadata.name(), diff --git a/web/packages/api/src/environment.ts b/web/packages/api/src/environment.ts index 8a1980bd4e..4f3ea4c5d9 100644 --- a/web/packages/api/src/environment.ts +++ b/web/packages/api/src/environment.ts @@ -12,6 +12,11 @@ export type Config = { SECONDARY_GOVERNANCE_CHANNEL_ID: string RELAYERS: Relayer[] PARACHAINS: string[] + SUBSCAN_API?: { + RELAY_CHAIN_URL: string + ASSET_HUB_URL: string + BRIDGE_HUB_URL: string + } } export type SourceType = "substrate" | "ethereum" @@ -21,6 +26,7 @@ export type ParachainInfo = { destinationFeeDOT: bigint has20ByteAccounts: boolean decimals: number + maxConsumers: number ss58Format?: number } export type TransferLocation = { @@ -61,6 +67,7 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { destinationFeeDOT: 0n, has20ByteAccounts: false, decimals: 12, + maxConsumers: 16, }, erc20tokensReceivable: { WETH: "0x87d1f7fdfEe7f651FaBc8bFCB6E086C278b77A7d", @@ -76,6 +83,7 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { destinationFeeDOT: 4_000_000_000n, has20ByteAccounts: false, decimals: 12, + maxConsumers: 16, }, erc20tokensReceivable: { WETH: "0x87d1f7fdfEe7f651FaBc8bFCB6E086C278b77A7d", @@ -165,6 +173,7 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { destinationFeeDOT: 0n, has20ByteAccounts: false, decimals: 12, + maxConsumers: 16, }, erc20tokensReceivable: { WETH: "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", @@ -182,6 +191,7 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { destinationFeeDOT: 200_000_000_000n, has20ByteAccounts: true, decimals: 12, + maxConsumers: 16, }, erc20tokensReceivable: { MUSE: "0xb34a6924a02100ba6ef12af1c798285e8f7a16ee", @@ -235,6 +245,11 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { type: "ethereum", }, ], + SUBSCAN_API: { + RELAY_CHAIN_URL: "https://rococo.api.subscan.io", + ASSET_HUB_URL: "https://assethub-rococo.api.subscan.io", + BRIDGE_HUB_URL: "https://bridgehub-rococo.api.subscan.io", + }, }, }, polkadot_mainnet: { @@ -259,6 +274,7 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { destinationFeeDOT: 0n, has20ByteAccounts: false, decimals: 10, + maxConsumers: 64, }, erc20tokensReceivable: { WETH: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", @@ -312,6 +328,11 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { type: "ethereum", }, ], + SUBSCAN_API: { + RELAY_CHAIN_URL: "https://polkadot.api.subscan.io", + ASSET_HUB_URL: "https://assethub-polkadot.api.subscan.io", + BRIDGE_HUB_URL: "https://bridgehub-polkadot.api.subscan.io", + }, }, }, } diff --git a/web/packages/api/src/history.ts b/web/packages/api/src/history.ts new file mode 100644 index 0000000000..58ad55d712 --- /dev/null +++ b/web/packages/api/src/history.ts @@ -0,0 +1,1062 @@ +import { Context } from "./index" +import { fetchBeaconSlot, paraIdToChannelId } from "./utils" +import { SubscanApi, fetchEvents, fetchExtrinsics } from "./subscan" +import { forwardedTopicId } from "./utils" + +export enum TransferStatus { + Pending, + Complete, + Failed, +} + +export type TransferInfo = { + when: Date + sourceAddress: string + beneficiaryAddress: string + tokenAddress: string + destinationParachain?: number + destinationFee?: string + amount: string +} + +export type ToPolkadotTransferResult = { + id: string + status: TransferStatus + info: TransferInfo + submitted: { + blockHash: string + blockNumber: number + logIndex: number + transactionHash: string + transactionIndex: number + channelId: string + messageId: string + nonce: number + parentBeaconSlot: number + } + beaconClientIncluded?: { + extrinsic_index: string + extrinsic_hash: string + event_index: string + block_timestamp: number + beaconSlot: number + beaconBlockHash: string + } + inboundMessageReceived?: { + extrinsic_index: string + extrinsic_hash: string + event_index: string + block_timestamp: number + messageId: string + channelId: string + nonce: number + } + assetHubMessageProcessed?: { + extrinsic_hash: string + event_index: string + block_timestamp: number + success: boolean + sibling: number + } +} + +export type ToEthereumTransferResult = { + id: string + status: TransferStatus + info: TransferInfo + submitted: { + extrinsic_index: string + extrinsic_hash: string + block_hash: string + account_id: string + block_num: number + block_timestamp: number + messageId: string + bridgeHubMessageId: string + success: boolean + relayChain: { + block_hash: string + block_num: number + } + } + bridgeHubXcmDelivered?: { + extrinsic_hash: string + event_index: string + block_timestamp: number + siblingParachain: number + success: boolean + } + bridgeHubChannelDelivered?: { + extrinsic_hash: string + event_index: string + block_timestamp: number + channelId: string + success: boolean + } + bridgeHubMessageQueued?: { + extrinsic_hash: string + event_index: string + block_timestamp: number + } + bridgeHubMessageAccepted?: { + extrinsic_hash: string + event_index: string + block_timestamp: number + nonce: number + } + ethereumBeefyIncluded?: { + blockNumber: number + blockHash: string + transactionHash: string + transactionIndex: number + logIndex: number + relayChainblockNumber: number + mmrRoot: string + } + ethereumMessageDispatched?: { + blockNumber: number + blockHash: string + transactionHash: string + transactionIndex: number + logIndex: number + messageId: string + channelId: string + nonce: number + success: boolean + } +} + +export const toPolkadotHistory = async ( + context: Context, + assetHubScan: SubscanApi, + bridgeHubScan: SubscanApi, + range: { + assetHub: { fromBlock: number; toBlock: number } + bridgeHub: { fromBlock: number; toBlock: number } + ethereum: { fromBlock: number; toBlock: number } + } +): Promise => { + console.log("Fetching history To Polkadot") + console.log( + `eth from ${range.ethereum.fromBlock} to ${range.ethereum.toBlock} (${ + range.ethereum.toBlock - range.ethereum.fromBlock + } blocks)` + ) + console.log( + `assethub from ${range.assetHub.fromBlock} to ${range.assetHub.toBlock} (${ + range.assetHub.toBlock - range.assetHub.fromBlock + } blocks)` + ) + console.log( + `bridgehub from ${range.bridgeHub.fromBlock} to ${range.bridgeHub.toBlock} (${ + range.bridgeHub.toBlock - range.bridgeHub.fromBlock + } blocks)` + ) + + const bridgeHubParaIdCodec = + await context.polkadot.api.bridgeHub.query.parachainInfo.parachainId() + const bridgeHubParaId = bridgeHubParaIdCodec.toPrimitive() as number + + const [ + ethOutboundMessages, + beaconClientUpdates, + inboundMessagesReceived, + assetHubMessageQueue, + ] = [ + await getEthOutboundMessages(context, range.ethereum.fromBlock, range.ethereum.toBlock), + + await getBeaconClientUpdates( + bridgeHubScan, + range.bridgeHub.fromBlock, + range.bridgeHub.toBlock + ), + + await getBridgeHubInboundMessages( + bridgeHubScan, + range.bridgeHub.fromBlock, + range.bridgeHub.toBlock + ), + + await getAssetHubMessageQueueProccessed( + assetHubScan, + bridgeHubParaId, + range.assetHub.fromBlock, + range.assetHub.toBlock + ), + ] + + console.log("number of transfers", ethOutboundMessages.length) + console.log("number of beacon client updates", beaconClientUpdates.length) + console.log("number of inbound messages received", inboundMessagesReceived.length) + console.log("number of asset hub message queue processed", assetHubMessageQueue.length) + + const results: ToPolkadotTransferResult[] = [] + for (const outboundMessage of ethOutboundMessages) { + const result: ToPolkadotTransferResult = { + id: `${outboundMessage.transactionHash}-${outboundMessage.data.messageId}`, + status: TransferStatus.Pending, + info: { + when: new Date(outboundMessage.data.timestamp * 1000), + sourceAddress: outboundMessage.data.sourceAddress, + beneficiaryAddress: outboundMessage.data.beneficiaryAddress, + tokenAddress: outboundMessage.data.tokenAddress, + destinationParachain: outboundMessage.data.destinationParachain, + destinationFee: outboundMessage.data.destinationFee, + amount: outboundMessage.data.amount, + }, + submitted: { + blockHash: outboundMessage.blockHash, + blockNumber: outboundMessage.blockNumber, + logIndex: outboundMessage.logIndex, + transactionHash: outboundMessage.transactionHash, + transactionIndex: outboundMessage.transactionIndex, + channelId: outboundMessage.data.channelId, + messageId: outboundMessage.data.messageId, + nonce: outboundMessage.data.nonce, + parentBeaconSlot: Number(outboundMessage.data.parentBeaconSlot), + }, + } + results.push(result) + + const beaconClientIncluded = beaconClientUpdates.find( + (ev) => ev.data.beaconSlot > result.submitted.parentBeaconSlot + 1 // add one to parent to get current + ) + if (beaconClientIncluded) { + result.beaconClientIncluded = { + extrinsic_index: beaconClientIncluded.extrinsic_index, + extrinsic_hash: beaconClientIncluded.extrinsic_hash, + event_index: beaconClientIncluded.event_index, + block_timestamp: beaconClientIncluded.block_timestamp, + beaconSlot: beaconClientIncluded.data.beaconSlot, + beaconBlockHash: beaconClientIncluded.data.beaconBlockHash, + } + } + + const inboundMessageReceived = inboundMessagesReceived.find( + (ev) => + ev.data.messageId === result.submitted.messageId && + ev.data.channelId === result.submitted.channelId && + ev.data.nonce === result.submitted.nonce + ) + if (inboundMessageReceived) { + result.inboundMessageReceived = { + extrinsic_index: inboundMessageReceived.extrinsic_index, + extrinsic_hash: inboundMessageReceived.extrinsic_hash, + event_index: inboundMessageReceived.event_index, + block_timestamp: inboundMessageReceived.block_timestamp, + messageId: inboundMessageReceived.data.messageId, + channelId: inboundMessageReceived.data.channelId, + nonce: inboundMessageReceived.data.nonce, + } + } + + const assetHubMessageProcessed = assetHubMessageQueue.find( + (ev) => + ev.data.sibling === bridgeHubParaId && + ev.data.messageId == result.submitted.messageId + ) + if (assetHubMessageProcessed) { + result.assetHubMessageProcessed = { + extrinsic_hash: assetHubMessageProcessed.extrinsic_hash, + event_index: assetHubMessageProcessed.event_index, + block_timestamp: assetHubMessageProcessed.block_timestamp, + success: assetHubMessageProcessed.data.success, + sibling: assetHubMessageProcessed.data.sibling, + } + if (!result.assetHubMessageProcessed.success) { + result.status = TransferStatus.Failed + continue + } + + result.status = TransferStatus.Complete + } + } + return results +} + +export const toEthereumHistory = async ( + context: Context, + assetHubScan: SubscanApi, + bridgeHubScan: SubscanApi, + relaychainScan: SubscanApi, + range: { + assetHub: { fromBlock: number; toBlock: number } + bridgeHub: { fromBlock: number; toBlock: number } + ethereum: { fromBlock: number; toBlock: number } + } +): Promise => { + console.log("Fetching history To Ethereum") + console.log( + `eth from ${range.ethereum.fromBlock} to ${range.ethereum.toBlock} (${ + range.ethereum.toBlock - range.ethereum.fromBlock + } blocks)` + ) + console.log( + `assethub from ${range.assetHub.fromBlock} to ${range.assetHub.toBlock} (${ + range.assetHub.toBlock - range.assetHub.fromBlock + } blocks)` + ) + console.log( + `bridgehub from ${range.bridgeHub.fromBlock} to ${range.bridgeHub.toBlock} (${ + range.bridgeHub.toBlock - range.bridgeHub.fromBlock + } blocks)` + ) + + const [ethNetwork, assetHubParaId] = await Promise.all([ + context.ethereum.api.getNetwork(), + context.polkadot.api.assetHub.query.parachainInfo.parachainId(), + ]) + const assetHubParaIdDecoded = assetHubParaId.toPrimitive() as number + const assetHubChannelId = paraIdToChannelId(assetHubParaIdDecoded) + + const [ + allTransfers, + allMessageQueues, + allOutboundMessages, + allBeefyClientUpdates, + allInboundMessages, + ] = [ + await getAssetHubTransfers( + assetHubScan, + relaychainScan, + Number(ethNetwork.chainId), + range.assetHub.fromBlock, + range.assetHub.toBlock + ), + + await getBridgeHubMessageQueueProccessed( + bridgeHubScan, + assetHubParaIdDecoded, + assetHubChannelId, + range.bridgeHub.fromBlock, + range.bridgeHub.toBlock + ), + + await getBridgeHubOutboundMessages( + bridgeHubScan, + range.bridgeHub.fromBlock, + range.bridgeHub.toBlock + ), + + await getBeefyClientUpdates(context, range.ethereum.fromBlock, range.ethereum.toBlock), + + await getEthInboundMessagesDispatched( + context, + range.ethereum.fromBlock, + range.ethereum.toBlock + ), + ] + + console.log("number of transfers", allTransfers.length) + console.log("number of message queues", allMessageQueues.length) + console.log("number of outbound messages", allOutboundMessages.length) + console.log("number of beefy updates", allBeefyClientUpdates.length) + console.log("number of inbound messages", allInboundMessages.length) + + const results: ToEthereumTransferResult[] = [] + for (const transfer of allTransfers) { + const result: ToEthereumTransferResult = { + id: `${transfer.extrinsic_hash}-${transfer.data.messageId}`, + status: TransferStatus.Pending, + info: { + when: new Date(transfer.block_timestamp * 1000), + sourceAddress: transfer.data.account_id, + tokenAddress: transfer.data.tokenAddress, + beneficiaryAddress: transfer.data.beneficiaryAddress, + amount: transfer.data.amount, + }, + submitted: { + extrinsic_index: transfer.extrinsic_index, + extrinsic_hash: transfer.extrinsic_hash, + block_hash: transfer.data.block_hash, + account_id: transfer.data.account_id, + block_num: transfer.block_num, + block_timestamp: transfer.block_timestamp, + messageId: transfer.data.messageId, + bridgeHubMessageId: transfer.data.bridgeHubMessageId, + success: transfer.data.success, + relayChain: { + block_num: transfer.data.relayChain.block_num, + block_hash: transfer.data.relayChain.block_hash, + }, + }, + } + results.push(result) + if (!result.submitted.success) { + result.status = TransferStatus.Failed + continue + } + + const bridgeHubXcmDelivered = allMessageQueues.find( + (ev: any) => + ev.data.messageId === result.submitted.bridgeHubMessageId && + ev.data.sibling == assetHubParaIdDecoded + ) + if (bridgeHubXcmDelivered) { + result.bridgeHubXcmDelivered = { + block_timestamp: bridgeHubXcmDelivered.block_timestamp, + event_index: bridgeHubXcmDelivered.event_index, + extrinsic_hash: bridgeHubXcmDelivered.extrinsic_hash, + siblingParachain: bridgeHubXcmDelivered.data.sibling, + success: bridgeHubXcmDelivered.data.success, + } + if (!result.bridgeHubXcmDelivered.success) { + result.status = TransferStatus.Failed + continue + } + } + const bridgeHubChannelDelivered = allMessageQueues.find( + (ev: any) => + ev.extrinsic_hash === result.bridgeHubXcmDelivered?.extrinsic_hash && + ev.data.channelId === assetHubChannelId && + ev.block_timestamp === result.bridgeHubXcmDelivered?.block_timestamp + ) + if (bridgeHubChannelDelivered) { + result.bridgeHubChannelDelivered = { + block_timestamp: bridgeHubChannelDelivered.block_timestamp, + event_index: bridgeHubChannelDelivered.event_index, + extrinsic_hash: bridgeHubChannelDelivered.extrinsic_hash, + channelId: bridgeHubChannelDelivered.data.channelId, + success: bridgeHubChannelDelivered.data.success, + } + if (!result.bridgeHubChannelDelivered.success) { + result.status = TransferStatus.Failed + continue + } + } + + const bridgeHubMessageQueued = allOutboundMessages.find( + (ev: any) => + ev.data.messageId === result.submitted.messageId && + ev.event_id === "MessageQueued" /* TODO: ChannelId */ + ) + if (bridgeHubMessageQueued) { + result.bridgeHubMessageQueued = { + block_timestamp: bridgeHubMessageQueued.block_timestamp, + event_index: bridgeHubMessageQueued.event_index, + extrinsic_hash: bridgeHubMessageQueued.extrinsic_hash, + } + } + const bridgeHubMessageAccepted = allOutboundMessages.find( + (ev: any) => + ev.data.messageId === result.submitted.messageId && + ev.event_id === "MessageAccepted" /* TODO: ChannelId */ + ) + if (bridgeHubMessageAccepted) { + result.bridgeHubMessageAccepted = { + block_timestamp: bridgeHubMessageAccepted.block_timestamp, + event_index: bridgeHubMessageAccepted.event_index, + extrinsic_hash: bridgeHubMessageAccepted.extrinsic_hash, + nonce: bridgeHubMessageAccepted.data.nonce, + } + } + + const secondsTillAcceptedByRelayChain = 6 /* 6 secs per block */ * 10 /* blocks */ + const ethereumBeefyIncluded = allBeefyClientUpdates.find( + (ev) => + ev.data.blockNumber > + result.submitted.relayChain.block_num + secondsTillAcceptedByRelayChain + ) + if (ethereumBeefyIncluded) { + result.ethereumBeefyIncluded = { + blockNumber: ethereumBeefyIncluded.blockNumber, + blockHash: ethereumBeefyIncluded.blockHash, + transactionHash: ethereumBeefyIncluded.transactionHash, + transactionIndex: ethereumBeefyIncluded.transactionIndex, + logIndex: ethereumBeefyIncluded.logIndex, + relayChainblockNumber: ethereumBeefyIncluded.data.blockNumber, + mmrRoot: ethereumBeefyIncluded.data.mmrRoot, + } + } + + const ethereumMessageDispatched = allInboundMessages.find( + (ev) => + ev.data.channelId === result.bridgeHubChannelDelivered?.channelId && + ev.data.messageId === result.submitted.messageId && + ev.data.nonce === result.bridgeHubMessageAccepted?.nonce + ) + + if (ethereumMessageDispatched) { + result.ethereumMessageDispatched = { + blockNumber: ethereumMessageDispatched.blockNumber, + blockHash: ethereumMessageDispatched.blockHash, + transactionHash: ethereumMessageDispatched.transactionHash, + transactionIndex: ethereumMessageDispatched.transactionIndex, + logIndex: ethereumMessageDispatched.logIndex, + messageId: ethereumMessageDispatched.data.messageId, + channelId: ethereumMessageDispatched.data.channelId, + nonce: ethereumMessageDispatched.data.nonce, + success: ethereumMessageDispatched.data.success, + } + if (!result.ethereumMessageDispatched.success) { + result.status = TransferStatus.Failed + continue + } + + result.status = TransferStatus.Complete + } + } + return results +} + +const getAssetHubTransfers = async ( + assetHubScan: SubscanApi, + relaychainScan: SubscanApi, + ethChainId: number, + fromBlock: number, + toBlock: number +) => { + const acc = [] + const rows = 100 + let page = 0 + + let endOfPages = false + while (!endOfPages) { + const { extrinsics: transfers, endOfPages: end } = await subFetchBridgeTransfers( + assetHubScan, + relaychainScan, + ethChainId, + fromBlock, + toBlock, + page, + rows + ) + endOfPages = end + acc.push(...transfers) + page++ + } + return acc +} + +const getBridgeHubMessageQueueProccessed = async ( + bridgeHubScan: SubscanApi, + assetHubParaId: number, + assetHubChannelId: string, + fromBlock: number, + toBlock: number +) => { + const acc = [] + const rows = 100 + let page = 0 + let endOfPages = false + while (!endOfPages) { + const { events, endOfPages: end } = await subFetchMessageQueueBySiblingOrChannel( + bridgeHubScan, + assetHubParaId, + assetHubChannelId, + fromBlock, + toBlock, + page, + rows + ) + endOfPages = end + acc.push(...events) + page++ + } + return acc +} + +const getBridgeHubOutboundMessages = async ( + bridgeHubScan: SubscanApi, + fromBlock: number, + toBlock: number +) => { + const acc = [] + const rows = 100 + let page = 0 + let endOfPages = false + while (!endOfPages) { + const { events, endOfPages: end } = await subFetchOutboundMessages( + bridgeHubScan, + fromBlock, + toBlock, + page, + rows + ) + endOfPages = end + acc.push(...events) + page++ + } + return acc +} + +const getBeefyClientUpdates = async (context: Context, fromBlock: number, toBlock: number) => { + const { beefyClient } = context.ethereum.contracts + const NewMMRRoot = beefyClient.getEvent("NewMMRRoot") + const roots = await beefyClient.queryFilter(NewMMRRoot, fromBlock, toBlock) + const updates = roots.map((r) => { + return { + blockNumber: r.blockNumber, + blockHash: r.blockHash, + logIndex: r.index, + transactionIndex: r.transactionIndex, + transactionHash: r.transactionHash, + data: { + blockNumber: Number(r.args.blockNumber), + mmrRoot: r.args.mmrRoot, + }, + } + }) + updates.sort((a, b) => Number(a.data.blockNumber - b.data.blockNumber)) + return updates +} + +const getEthInboundMessagesDispatched = async ( + context: Context, + fromBlock: number, + toBlock: number +) => { + const { gateway } = context.ethereum.contracts + const InboundMessageDispatched = gateway.getEvent("InboundMessageDispatched") + const inboundMessages = await gateway.queryFilter(InboundMessageDispatched, fromBlock, toBlock) + return inboundMessages.map((im) => { + return { + blockNumber: im.blockNumber, + blockHash: im.blockHash, + logIndex: im.index, + transactionIndex: im.transactionIndex, + transactionHash: im.transactionHash, + data: { + channelId: im.args.channelID, + nonce: Number(im.args.nonce), + messageId: im.args.messageID, + success: im.args.success, + }, + } + }) +} + +const subFetchBridgeTransfers = async ( + assetHub: SubscanApi, + relaychain: SubscanApi, + ethChainId: number, + fromBlock: number, + toBlock: number, + page: number, + rows = 10 +) => { + return fetchExtrinsics( + assetHub, + "polkadotxcm", + "transfer_assets", + fromBlock, + toBlock, + page, + rows, + async (extrinsic, params) => { + const dest = params.find((p: any) => p.name == "dest") + const parents: number | null = dest.value.V3?.parents ?? dest.value.V4?.parents ?? null + const chainId: number | null = + dest.value.V3?.interior?.X1?.GlobalConsensus?.Ethereum ?? + (dest.value.V4?.interior?.X1 && dest.value.V4?.interior?.X1[0])?.GlobalConsensus + ?.Ethereum ?? + null + + if (!(parents === 2 && chainId === ethChainId)) { + return null + } + + const beneficiary = params.find((p: any) => p.name == "beneficiary")?.value + const beneficiaryParents: number | null = + beneficiary.V3?.parents ?? beneficiary.V4?.parents ?? null + const beneficiaryAddress: string | null = + beneficiary.V3?.interior?.X1?.AccountKey20?.key ?? + (beneficiary.V4?.interior?.X1 && beneficiary.V4?.interior?.X1[0])?.AccountKey20 + ?.key ?? + null + + if (!(beneficiaryParents === 0 && beneficiaryAddress !== null)) { + return null + } + + const assets = params.find((p: any) => p.name == "assets")?.value + let amount: string | null = null + let tokenParents: number | null = null + let tokenAddress: string | null = null + let tokenChainId: number | null = null + for (const asset of assets.V3 ?? assets.V4 ?? []) { + amount = asset.fun?.Fungible ?? null + if (amount === null) { + continue + } + + tokenParents = asset.id?.parents ?? asset.id?.Concrete?.parents ?? null + if (tokenParents === null) { + continue + } + + const tokenX2 = + asset.id?.interior?.X2 ?? Object.values(asset.id?.Concrete?.interior?.X2 ?? {}) + if (tokenX2 === null || tokenX2.length !== 2) { + continue + } + + tokenChainId = tokenX2[0].GlobalConsensus?.Ethereum ?? null + if (tokenChainId === null) { + continue + } + + tokenAddress = tokenX2[1].AccountKey20?.key ?? null + if (tokenAddress === null) { + continue + } + + // found first token + break + } + + if ( + !( + tokenParents === 2 && + tokenChainId === ethChainId && + tokenAddress !== null && + amount !== null + ) + ) { + return null + } + + const [ + { + json: { data: transfer }, + }, + { + json: { data: relayBlock }, + }, + ] = [ + await assetHub.post("api/scan/extrinsic", { + extrinsic_index: extrinsic.extrinsic_index, + only_extrinsic_event: true, + }), + await relaychain.post("api/scan/block", { + block_timestamp: extrinsic.block_timestamp, + only_head: true, + }), + ] + const maybeEvent = transfer.event.find( + (ev: any) => ev.module_id === "polkadotxcm" && ev.event_id === "Sent" + ) + let messageId: string | null = null + let bridgeHubMessageId: string | null = null + + if (transfer.success && maybeEvent) { + const ev = JSON.parse(maybeEvent.params) + messageId = ev.find((pa: any) => pa.name === "message_id")?.value ?? null + if (messageId) { + bridgeHubMessageId = forwardedTopicId(messageId) + } + } + + const success = + transfer.event.find( + (ev: any) => ev.module_id === "system" && ev.event_id === "ExtrinsicSuccess" + ) !== undefined + + return { + events: transfer.events, + messageId, + bridgeHubMessageId, + success, + block_hash: transfer.block_hash, + account_id: transfer.account_id, + relayChain: { block_num: relayBlock.block_num, block_hash: relayBlock.hash }, + tokenAddress, + beneficiaryAddress, + amount, + } + } + ) +} + +const subFetchMessageQueueBySiblingOrChannel = async ( + api: SubscanApi, + filterSibling: number, + filterChannelId: string, + fromBlock: number, + toBlock: number, + page: number, + rows = 10 +) => { + return fetchEvents( + api, + "messagequeue", + ["Processed", "ProcessingFailed", "OverweightEnqueued"], + fromBlock, + toBlock, + page, + rows, + async (event, params) => { + const messageId = params.find((e: any) => e.name === "id")?.value + if (!messageId) { + return null + } + + const origin = params.find((e: any) => e.name === "origin")?.value + const sibling = origin?.Sibling ?? null + const channelId = origin?.Snowbridge ?? null + + if (sibling === null && channelId !== filterChannelId) { + return null + } + if (channelId === null && sibling !== filterSibling) { + return null + } + if (channelId === null && sibling === null) { + return null + } + + let success = + event.event_id === "Processed" && + (params.find((e: any) => e.name === "success")?.value ?? false) + + return { messageId, sibling, channelId, success } + } + ) +} + +const subFetchMessageQueueBySibling = async ( + api: SubscanApi, + filterSibling: number, + fromBlock: number, + toBlock: number, + page: number, + rows = 10 +) => { + return fetchEvents( + api, + "messagequeue", + ["Processed", "ProcessingFailed", "OverweightEnqueued"], + fromBlock, + toBlock, + page, + rows, + async (event, params) => { + const messageId = params.find((e: any) => e.name === "id")?.value + if (!messageId) { + return null + } + + const origin = params.find((e: any) => e.name === "origin")?.value + const sibling = origin?.Sibling + + if (sibling !== filterSibling) { + return null + } + + let success = + event.event_id === "Processed" && + (params.find((e: any) => e.name === "success")?.value ?? false) + + return { messageId, sibling, success } + } + ) +} + +const subFetchOutboundMessages = async ( + api: SubscanApi, + fromBlock: number, + toBlock: number, + page: number, + rows = 10 +) => { + return fetchEvents( + api, + "ethereumoutboundqueue", + ["MessageAccepted", "MessageQueued"], + fromBlock, + toBlock, + page, + rows, + async (_, params) => { + const messageId = params.find((e: any) => e.name === "id")?.value + // TODO: channelId + const nonce = params.find((e: any) => e.name === "nonce")?.value ?? null + return { messageId, nonce } + } + ) +} + +const getEthOutboundMessages = async (context: Context, fromBlock: number, toBlock: number) => { + const { gateway } = context.ethereum.contracts + const OutboundMessageAccepted = gateway.getEvent("OutboundMessageAccepted") + const outboundMessages = await gateway.queryFilter(OutboundMessageAccepted, fromBlock, toBlock) + const result = [] + for (const om of outboundMessages) { + const block = await om.getBlock() + const beaconBlockRoot = await fetchBeaconSlot( + context.config.ethereum.beacon_url, + block.parentBeaconBlockRoot as any + ) + const transaction = await block.getTransaction(om.transactionHash) + const [ + tokenAddress, + destinationParachain, + [addressType, beneficiaryAddress], + destinationFee, + amount, + ] = context.ethereum.contracts.gateway.interface.decodeFunctionData( + "sendToken", + transaction.data + ) + let beneficiary = beneficiaryAddress as string + switch (addressType) { + case 0n: + { + // 4-byte index + const index = BigInt(beneficiary.substring(0, 6)) + beneficiary = index.toString() + } + break + case 2n: + { + // 20-byte address + beneficiary = beneficiary.substring(0, 42) + } + break + } + + result.push({ + blockNumber: om.blockNumber, + blockHash: om.blockHash, + logIndex: om.index, + transactionIndex: om.transactionIndex, + transactionHash: om.transactionHash, + data: { + sourceAddress: transaction.from, + timestamp: block.timestamp, + channelId: om.args.channelID, + nonce: Number(om.args.nonce), + messageId: om.args.messageID, + parentBeaconSlot: Number(beaconBlockRoot.data.message.slot), + tokenAddress: tokenAddress as string, + destinationParachain: Number(destinationParachain), + beneficiaryAddress: beneficiary, + destinationFee: destinationFee.toString() as string, + amount: amount.toString() as string, + }, + }) + } + return result +} + +const getBeaconClientUpdates = async ( + bridgeHubScan: SubscanApi, + fromBlock: number, + toBlock: number +) => { + const updates = [] + const rows = 100 + let page = 0 + let endOfPages = false + while (!endOfPages) { + const { events, endOfPages: end } = await subFetchBeaconHeaderImports( + bridgeHubScan, + fromBlock, + toBlock, + page, + rows + ) + endOfPages = end + updates.push(...events) + page++ + } + updates.sort((a, b) => Number(a.data.beaconSlot - b.data.beaconSlot)) + return updates +} + +const getBridgeHubInboundMessages = async ( + bridgeHubScan: SubscanApi, + fromBlock: number, + toBlock: number +) => { + const updates = [] + const rows = 100 + let page = 0 + let endOfPages = false + while (!endOfPages) { + const { events, endOfPages: end } = await subFetchInboundMessageReceived( + bridgeHubScan, + fromBlock, + toBlock, + page, + rows + ) + endOfPages = end + updates.push(...events) + page++ + } + return updates +} + +const getAssetHubMessageQueueProccessed = async ( + bridgeHubScan: SubscanApi, + bridgeHubParaId: number, + fromBlock: number, + toBlock: number +) => { + const acc = [] + const rows = 100 + let page = 0 + let endOfPages = false + while (!endOfPages) { + const { events, endOfPages: end } = await subFetchMessageQueueBySibling( + bridgeHubScan, + bridgeHubParaId, + fromBlock, + toBlock, + page, + rows + ) + endOfPages = end + acc.push(...events) + page++ + } + return acc +} + +const subFetchBeaconHeaderImports = async ( + api: SubscanApi, + fromBlock: number, + toBlock: number, + page: number, + rows = 10 +) => { + return fetchEvents( + api, + "ethereumbeaconclient", + ["BeaconHeaderImported"], + fromBlock, + toBlock, + page, + rows, + async (_, params) => { + const beaconBlockHash = params.find((e: any) => e.name === "block_hash")?.value + const beaconSlot = params.find((e: any) => e.name === "slot")?.value + return { beaconBlockHash, beaconSlot } + } + ) +} + +const subFetchInboundMessageReceived = async ( + api: SubscanApi, + fromBlock: number, + toBlock: number, + page: number, + rows = 10 +) => { + return fetchEvents( + api, + "ethereuminboundqueue", + ["MessageReceived"], + fromBlock, + toBlock, + page, + rows, + async (_, params) => { + const channelId = params.find((e: any) => e.name === "channel_id")?.value + const nonce = params.find((e: any) => e.name === "nonce")?.value + const messageId = params.find((e: any) => e.name === "message_id")?.value + return { channelId, nonce, messageId } + } + ) +} diff --git a/web/packages/api/src/index.ts b/web/packages/api/src/index.ts index c721e914a8..6687112246 100644 --- a/web/packages/api/src/index.ts +++ b/web/packages/api/src/index.ts @@ -54,19 +54,26 @@ class EthereumContext { } } +type Parachains = { [paraId: number]: ApiPromise } + class PolkadotContext { api: { relaychain: ApiPromise assetHub: ApiPromise bridgeHub: ApiPromise - parachains: { [paraId: number]: ApiPromise } + parachains: Parachains } - constructor(relaychain: ApiPromise, assetHub: ApiPromise, bridgeHub: ApiPromise) { + constructor( + relaychain: ApiPromise, + assetHub: ApiPromise, + bridgeHub: ApiPromise, + parachains: Parachains + ) { this.api = { relaychain: relaychain, assetHub: assetHub, bridgeHub: bridgeHub, - parachains: {}, + parachains: parachains, } } } @@ -79,15 +86,32 @@ export const contextFactory = async (config: Config): Promise => { } else { ethApi = new ethers.WebSocketProvider(config.ethereum.execution_url) } - const relaychainApi = await ApiPromise.create({ - provider: new WsProvider(config.polkadot.url.relaychain), - }) - const assetHubApi = await ApiPromise.create({ - provider: new WsProvider(config.polkadot.url.assetHub), - }) - const bridgeHubApi = await ApiPromise.create({ - provider: new WsProvider(config.polkadot.url.bridgeHub), - }) + + const parasConnect: Promise<{ paraId: number; api: ApiPromise }>[] = [] + for (const parachain of config.polkadot.url.parachains ?? []) { + parasConnect.push(addParachainConnection(parachain)) + } + + const [relaychainApi, assetHubApi, bridgeHubApi] = await Promise.all([ + ApiPromise.create({ + provider: new WsProvider(config.polkadot.url.relaychain), + }), + ApiPromise.create({ + provider: new WsProvider(config.polkadot.url.assetHub), + }), + ApiPromise.create({ + provider: new WsProvider(config.polkadot.url.bridgeHub), + }), + ]) + + const paras = await Promise.all(parasConnect) + const parachains: Parachains = {} + for (const { paraId, api } of paras) { + if (paraId in parachains) { + throw new Error(`${paraId} already added.`) + } + parachains[paraId] = api + } const gatewayAddr = config.appContracts.gateway const beefyAddr = config.appContracts.beefy @@ -100,25 +124,20 @@ export const contextFactory = async (config: Config): Promise => { } const ethCtx = new EthereumContext(ethApi, appContracts) - const polCtx = new PolkadotContext(relaychainApi, assetHubApi, bridgeHubApi) + const polCtx = new PolkadotContext(relaychainApi, assetHubApi, bridgeHubApi, parachains) const context = new Context(config, ethCtx, polCtx) - for (const parachain of config.polkadot.url.parachains ?? []) { - await addParachainConnection(context, parachain) - } + await Promise.all(parasConnect) return context } -export const addParachainConnection = async (context: Context, url: string): Promise => { +export const addParachainConnection = async (url: string) => { const api = await ApiPromise.create({ provider: new WsProvider(url), }) const paraId = (await api.query.parachainInfo.parachainId()).toPrimitive() as number - if (paraId in context.polkadot.api.parachains) { - throw new Error(`${paraId} already added.`) - } - context.polkadot.api.parachains[paraId] = api console.log(`${url} added with parachain id: ${paraId}`) + return { paraId, api } } export const destroyContext = async (context: Context): Promise => { @@ -142,3 +161,5 @@ export * as utils from "./utils" export * as status from "./status" export * as assets from "./assets" export * as environment from "./environment" +export * as subscan from "./subscan" +export * as history from "./history" diff --git a/web/packages/api/src/subscan.ts b/web/packages/api/src/subscan.ts new file mode 100644 index 0000000000..20c4afaa4e --- /dev/null +++ b/web/packages/api/src/subscan.ts @@ -0,0 +1,204 @@ +export type SubscanResult = { + status: number + statusText: string + json: any + rateLimit: SubscanRateLimit +} + +export type SubscanRateLimit = { + limit: number | null + reset: number | null + remaining: number | null + retryAfter: number | null +} + +export type SubscanApiPost = (subUrl: string, body: any) => Promise +export interface SubscanApi { + post: SubscanApiPost +} + +const sleepMs = async (ms: number) => { + await new Promise((resolve) => { + const id = setTimeout(() => { + resolve() + clearTimeout(id) + }, ms) + }) +} + +export const createApi = (baseUrl: string, apiKey: string, options = { limit: 1 }): SubscanApi => { + let url = baseUrl.trim() + if (!url.endsWith("/")) { + url += "/" + } + + const headers = new Headers() + headers.append("Content-Type", "application/json") + headers.append("x-api-key", apiKey) + + let rateLimit: SubscanRateLimit = { + limit: options.limit, + reset: 0, + remaining: options.limit, + retryAfter: 0, + } + const post: SubscanApiPost = async (subUrl: string, body: any) => { + const request: RequestInit = { + method: "POST", + headers, + body: JSON.stringify(body), + redirect: "follow", + } + + if (rateLimit.retryAfter !== null && rateLimit.retryAfter > 0) { + console.log("Being rate limited", rateLimit) + await sleepMs(rateLimit.retryAfter * 1000) + } + if (rateLimit.remaining === 0 && rateLimit.reset !== null && rateLimit.reset > 0) { + console.log("Being rate limited", rateLimit) + await sleepMs(rateLimit.reset * 1000) + } + + const response = await fetch(`${url}${subUrl}`, request) + + rateLimit.limit = Number(response.headers.get("ratelimit-limit")) + rateLimit.reset = Number(response.headers.get("ratelimit-reset")) + rateLimit.remaining = Number(response.headers.get("ratelimit-remaining")) + rateLimit.retryAfter = Number(response.headers.get("retry-after")) + + if (response.status !== 200) { + throw new Error( + `Failed to fetch from Subscan: ${response.status} ${response.statusText}` + ) + } + + const json = await response.json() + return { + status: response.status, + statusText: response.statusText, + json, + rateLimit: { ...rateLimit }, + } + } + + return { + post, + } +} + +export const fetchEvents = async ( + api: SubscanApi, + module: string, + eventIds: string[], + fromBlock: number, + toBlock: number, + page: number, + rows: number, + filterMap: (events: any, params: any) => Promise +) => { + const eventsBody = { + module, + block_range: `${fromBlock}-${toBlock}`, + event_id: eventIds.length === 1 ? eventIds[0] : undefined, + row: rows, + page, + } + + const eventResponse = await api.post("api/v2/scan/events", eventsBody) + + let endOfPages = false + if (eventResponse.json.data.events === null) { + eventResponse.json.data.events = [] + endOfPages = true + } + + const map = new Map() + eventResponse.json.data.events + .filter((e: any) => eventIds.includes(e.event_id)) + .forEach((e: any) => { + map.set(e.event_index, e) + }) + + const events = [] + + if (map.size > 0) { + const paramsBody = { event_index: Array.from(map.keys()) } + const paramsResponse = await api.post("api/scan/event/params", paramsBody) + + if (paramsResponse.json.data === null) { + paramsResponse.json.data = [] + } + + for (const { event_index, params } of paramsResponse.json.data) { + const event = map.get(event_index) + const transform = await filterMap(event, params) + if (transform === null) { + continue + } + events.push({ ...event, params, data: transform }) + } + } + return { + status: eventResponse.status, + statusText: eventResponse.statusText, + events, + endOfPages, + } +} + +export const fetchExtrinsics = async ( + api: SubscanApi, + module: string, + call: string, + fromBlock: number, + toBlock: number, + page: number, + rows: number, + filterMap: (extrinsic: any, params: any) => Promise +) => { + const extBody = { + module, + call, + block_range: `${fromBlock}-${toBlock}`, + row: rows, + page, + } + const extResponse = await api.post("api/v2/scan/extrinsics", extBody) + + let endOfPages = false + if (extResponse.json.data.extrinsics === null) { + extResponse.json.data.extrinsics = [] + endOfPages = true + } + const map = new Map() + extResponse.json.data.extrinsics.forEach((e: any) => { + map.set(e.extrinsic_index, e) + }) + + const extrinsics = [] + + if (map.size > 0) { + const paramsBody = { extrinsic_index: Array.from(map.keys()) } + const extParams = await api.post("api/scan/extrinsic/params", paramsBody) + + if (extParams.json.data === null) { + extParams.json.data = [] + } + + for (const { extrinsic_index, params } of extParams.json.data) { + const event = map.get(extrinsic_index) + const transform = await filterMap(event, params) + if (transform === null) { + continue + } + + extrinsics.push({ ...event, params, data: transform }) + } + } + return { + status: extResponse.status, + statusText: extResponse.statusText, + extrinsics, + endOfPages, + } +} diff --git a/web/packages/api/src/toEthereum.ts b/web/packages/api/src/toEthereum.ts index e2fd62baff..fb06b70560 100644 --- a/web/packages/api/src/toEthereum.ts +++ b/web/packages/api/src/toEthereum.ts @@ -72,6 +72,16 @@ export type SendValidationResult = { } } +export interface IValidateOptions { + defaultFee: bigint + acceptableLatencyInSeconds: number +} + +const ValidateOptionDefaults: IValidateOptions = { + defaultFee: 2_750_872_500_000n, + acceptableLatencyInSeconds: 28800 /* 8 Hours */, +} + export const getSendFee = async ( context: Context, options = { @@ -97,11 +107,9 @@ export const validateSend = async ( beneficiary: string, tokenAddress: string, amount: bigint, - options = { - defaultFee: 2_750_872_500_000n, - acceptableLatencyInSeconds: 28800 /* 8 Hours */, - } + validateOptions: Partial = {} ): Promise => { + const options = { ...ValidateOptionDefaults, ...validateOptions } const { ethereum, ethereum: { diff --git a/web/packages/api/src/toPolkadot.ts b/web/packages/api/src/toPolkadot.ts index b5742681b2..422aa7f1c7 100644 --- a/web/packages/api/src/toPolkadot.ts +++ b/web/packages/api/src/toPolkadot.ts @@ -3,7 +3,7 @@ import { Codec } from "@polkadot/types/types" import { u8aToHex } from "@polkadot/util" import { IERC20__factory, IGateway__factory, WETH9__factory } from "@snowbridge/contract-types" import { MultiAddressStruct } from "@snowbridge/contract-types/src/IGateway" -import { LogDescription, Signer, TransactionReceipt, ethers, keccak256 } from "ethers" +import { LogDescription, Signer, TransactionReceipt, ethers } from "ethers" import { concatMap, filter, firstValueFrom, lastValueFrom, take, takeWhile, tap } from "rxjs" import { assetStatusInfo } from "./assets" import { Context } from "./index" @@ -15,11 +15,13 @@ import { paraIdToChannelId, paraIdToSovereignAccount, } from "./utils" +import { ApiPromise } from "@polkadot/api" export enum SendValidationCode { BridgeNotOperational, ChannelNotOperational, BeneficiaryAccountMissing, + BeneficiaryHasHitMaxConsumers, ForeignAssetMissing, ERC20InvalidToken, ERC20NotRegistered, @@ -68,9 +70,20 @@ export type SendValidationResult = { tokenBalance: bigint tokenSpendAllowance: bigint lightClientLatencySeconds: number + accountConsumers: number | null } } +export interface IValidateOptions { + acceptableLatencyInSeconds: number /* 3 Hours */ + maxConsumers: number +} + +const ValidateOptionDefaults: IValidateOptions = { + acceptableLatencyInSeconds: 28800 /* 3 Hours */, + maxConsumers: 16, +} + export const approveTokenSpend = async ( context: Context, signer: Signer, @@ -105,6 +118,14 @@ export const getSendFee = async ( return await gateway.quoteSendTokenFee(tokenAddress, destinationParaId, destinationFee) } +export const getSubstrateAccount = async (parachain: ApiPromise, beneficiaryHex: string) => { + const account = (await parachain.query.system.account(beneficiaryHex)).toPrimitive() as { + data: { free: string } + consumers: number + } + return { balance: account.data.free, consumers: account.consumers } +} + export const validateSend = async ( context: Context, source: ethers.Addressable, @@ -113,14 +134,13 @@ export const validateSend = async ( destinationParaId: number, amount: bigint, destinationFee: bigint, - options = { - acceptableLatencyInSeconds: 28800 /* 3 Hours */, - } + validateOptions: Partial = {} ): Promise => { + const options = { ...ValidateOptionDefaults, ...validateOptions } const { ethereum, polkadot: { - api: { assetHub, bridgeHub, relaychain }, + api: { assetHub, bridgeHub, relaychain, parachains }, }, } = context @@ -194,9 +214,11 @@ export const validateSend = async ( let { address: beneficiaryAddress, hexAddress: beneficiaryHex } = beneficiaryMultiAddress(beneficiary) - let beneficiaryAccountExists = true + let beneficiaryAccountExists = false + let hasConsumers = false let destinationChainExists = true let hrmpChannelSetup = true + let accountConsumers: number | null = null const existentialDeposit = BigInt( assetHub.consts.balances.existentialDeposit.toPrimitive() as number ) @@ -204,10 +226,10 @@ export const validateSend = async ( if (destinationFee !== 0n) throw new Error("Asset Hub does not require a destination fee.") if (beneficiaryAddress.kind !== 1) throw new Error("Asset Hub only supports 32 byte addresses.") - const account = (await assetHub.query.system.account(beneficiaryHex)).toPrimitive() as { - data: { free: string } - } - beneficiaryAccountExists = BigInt(account.data.free) > existentialDeposit + const { balance, consumers } = await getSubstrateAccount(assetHub, beneficiaryHex) + beneficiaryAccountExists = BigInt(balance) > existentialDeposit + hasConsumers = consumers + 2 <= options.maxConsumers + accountConsumers = consumers } else { const [destinationHead, hrmpChannel] = await Promise.all([ relaychain.query.paras.heads(destinationParaId), @@ -218,6 +240,17 @@ export const validateSend = async ( ]) destinationChainExists = destinationHead.toPrimitive() !== null hrmpChannelSetup = hrmpChannel.toPrimitive() !== null + + if (destinationParaId in parachains) { + const { balance, consumers } = await getSubstrateAccount(assetHub, beneficiaryHex) + beneficiaryAccountExists = BigInt(balance) > existentialDeposit + hasConsumers = consumers + 2 <= options.maxConsumers + accountConsumers = consumers + } else { + // We cannot check this as we do not know the destination. + beneficiaryAccountExists = true + hasConsumers = true + } } if (!destinationChainExists) errors.push({ @@ -229,6 +262,11 @@ export const validateSend = async ( code: SendValidationCode.BeneficiaryAccountMissing, message: "Beneficiary does not hold existential deposit on destination.", }) + if (!hasConsumers) + errors.push({ + code: SendValidationCode.BeneficiaryHasHitMaxConsumers, + message: "Benificiary is approaching the asset consumer limit. Transfer may fail.", + }) if (!hrmpChannelSetup) errors.push({ code: SendValidationCode.NoHRMPChannelToDestination, @@ -300,6 +338,7 @@ export const validateSend = async ( tokenBalance: assetInfo.ownerBalance, tokenSpendAllowance: assetInfo.tokenGatewayAllowance, existentialDeposit: existentialDeposit, + accountConsumers: accountConsumers, }, } } @@ -381,7 +420,6 @@ export const send = async ( ]) const contract = IGateway__factory.connect(context.config.appContracts.gateway, signer) - const fees = await context.ethereum.api.getFeeData() const response = await contract.sendToken( success.token, diff --git a/web/packages/contract-types/package.json b/web/packages/contract-types/package.json index d4143f9e88..c16cf18234 100644 --- a/web/packages/contract-types/package.json +++ b/web/packages/contract-types/package.json @@ -1,6 +1,6 @@ { "name": "@snowbridge/contract-types", - "version": "0.1.5", + "version": "0.1.6", "description": "Snowbridge contract type bindings", "license": "Apache-2.0", "repository": { diff --git a/web/packages/operations/src/global_transfer_history.ts b/web/packages/operations/src/global_transfer_history.ts new file mode 100644 index 0000000000..826dc16ffd --- /dev/null +++ b/web/packages/operations/src/global_transfer_history.ts @@ -0,0 +1,99 @@ +import { contextFactory, destroyContext, environment, subscan, history } from "@snowbridge/api" + +const monitor = async () => { + const subscanKey = process.env.REACT_APP_SUBSCAN_KEY ?? "" + + let env = "rococo_sepolia" + if (process.env.NODE_ENV !== undefined) { + env = process.env.NODE_ENV + } + const snwobridgeEnv = environment.SNOWBRIDGE_ENV[env] + if (snwobridgeEnv === undefined) { + throw Error(`Unknown environment '${env}'`) + } + + const { config } = snwobridgeEnv + if (!config.SUBSCAN_API) throw Error(`Environment ${env} does not support subscan.`) + + const context = await contextFactory({ + ethereum: { + execution_url: config.ETHEREUM_WS_API(process.env.REACT_APP_ALCHEMY_KEY ?? ""), + beacon_url: config.BEACON_HTTP_API, + }, + polkadot: { + url: { + bridgeHub: config.BRIDGE_HUB_WS_URL, + assetHub: config.ASSET_HUB_WS_URL, + relaychain: config.RELAY_CHAIN_WS_URL, + parachains: config.PARACHAINS, + }, + }, + appContracts: { + gateway: config.GATEWAY_CONTRACT, + beefy: config.BEEFY_CONTRACT, + }, + }) + + const ethBlockTimeSeconds = 12 + const polkadotBlockTimeSeconds = 9 + const ethereumSearchPeriodBlocks = (60 * 60 * 24 * 7 * 2) / ethBlockTimeSeconds // 2 Weeks + const polkadotSearchPeriodBlocks = (60 * 60 * 24 * 7 * 2) / polkadotBlockTimeSeconds // 2 Weeks + + const assetHubScan = subscan.createApi(config.SUBSCAN_API.ASSET_HUB_URL, subscanKey) + const bridgeHubScan = subscan.createApi(config.SUBSCAN_API.BRIDGE_HUB_URL, subscanKey) + const relaychainScan = subscan.createApi(config.SUBSCAN_API.RELAY_CHAIN_URL, subscanKey) + + const [ethNowBlock, assetHubNowBlock, bridgeHubNowBlock] = await Promise.all([ + (async () => { + const ethNowBlock = await context.ethereum.api.getBlock("latest") + if (ethNowBlock == null) throw Error("Cannot fetch block") + return ethNowBlock + })(), + context.polkadot.api.assetHub.rpc.chain.getHeader(), + context.polkadot.api.bridgeHub.rpc.chain.getHeader(), + ]) + + const [toEthereum, toPolkadot] = [ + await history.toEthereumHistory(context, assetHubScan, bridgeHubScan, relaychainScan, { + assetHub: { + fromBlock: assetHubNowBlock.number.toNumber() - polkadotSearchPeriodBlocks, + toBlock: assetHubNowBlock.number.toNumber(), + }, + bridgeHub: { + fromBlock: bridgeHubNowBlock.number.toNumber() - polkadotSearchPeriodBlocks, + toBlock: bridgeHubNowBlock.number.toNumber(), + }, + ethereum: { + fromBlock: ethNowBlock.number - ethereumSearchPeriodBlocks, + toBlock: ethNowBlock.number, + }, + }), + await history.toPolkadotHistory(context, assetHubScan, bridgeHubScan, { + assetHub: { + fromBlock: assetHubNowBlock.number.toNumber() - polkadotSearchPeriodBlocks, + toBlock: assetHubNowBlock.number.toNumber(), + }, + bridgeHub: { + fromBlock: bridgeHubNowBlock.number.toNumber() - polkadotSearchPeriodBlocks, + toBlock: bridgeHubNowBlock.number.toNumber(), + }, + ethereum: { + fromBlock: ethNowBlock.number - ethereumSearchPeriodBlocks, + toBlock: ethNowBlock.number, + }, + }), + ] + + const transfers = [...toEthereum, ...toPolkadot] + transfers.sort((a, b) => b.info.when.getTime() - a.info.when.getTime()) + console.log(JSON.stringify(transfers, null, 2)) + + await destroyContext(context) +} + +monitor() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Error:", error) + process.exit(1) + }) diff --git a/web/packages/operations/src/transfer_token.ts b/web/packages/operations/src/transfer_token.ts index 38c84c8be8..ab9a45bea3 100644 --- a/web/packages/operations/src/transfer_token.ts +++ b/web/packages/operations/src/transfer_token.ts @@ -64,7 +64,7 @@ const monitor = async () => { amount, BigInt(0) ) - console.log("Plan:", plan) + console.log("Plan:", plan, plan.failure?.errors) let result = await toPolkadot.send(context, ETHEREUM_ACCOUNT, plan) console.log("Execute:", result) while (true) { @@ -87,7 +87,7 @@ const monitor = async () => { WETH_CONTRACT, amount ) - console.log("Plan:", plan) + console.log("Plan:", plan, plan.failure?.errors) const result = await toEthereum.send(context, POLKADOT_ACCOUNT, plan) console.log("Execute:", result) while (true) { @@ -111,7 +111,7 @@ const monitor = async () => { amount, BigInt(4_000_000_000) ) - console.log("Plan:", plan) + console.log("Plan:", plan, plan.failure?.errors) let result = await toPolkadot.send(context, ETHEREUM_ACCOUNT, plan) console.log("Execute:", result) while (true) { @@ -134,7 +134,7 @@ const monitor = async () => { WETH_CONTRACT, amount ) - console.log("Plan:", plan) + console.log("Plan:", plan, plan.failure?.errors) const result = await toEthereum.send(context, POLKADOT_ACCOUNT, plan) console.log("Execute:", result) while (true) { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 61ed8fe116..8f4e05f2a4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -344,9 +344,6 @@ importers: '@types/lodash': specifier: ^4.14.186 version: 4.14.187 - '@types/node': - specifier: ^18.13.0 - version: 18.16.8 '@types/secp256k1': specifier: ^4.0.3 version: 4.0.3 @@ -384,6 +381,9 @@ importers: specifier: ^3.0.5 version: 3.0.5 devDependencies: + '@types/node': + specifier: ^18.16.8 + version: 18.19.31 '@typescript-eslint/eslint-plugin': specifier: ^5.42.0 version: 5.42.0(@typescript-eslint/parser@5.42.0)(eslint@8.26.0)(typescript@5.1.6) @@ -398,7 +398,7 @@ importers: version: 8.5.0(eslint@8.26.0) ts-node: specifier: ^10.9.1 - version: 10.9.1(@types/node@18.16.8)(typescript@5.1.6) + version: 10.9.1(@types/node@18.19.31)(typescript@5.1.6) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -3036,6 +3036,7 @@ packages: /@types/node@18.16.8: resolution: {integrity: sha512-p0iAXcfWCOTCBbsExHIDFCfwsqFwBTgETJveKMT+Ci3LY9YqQCI91F5S+TB20+aRCXpcWfvx5Qr5EccnwCm2NA==} + dev: true /@types/node@18.19.31: resolution: {integrity: sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==} @@ -7378,7 +7379,7 @@ packages: yn: 3.1.1 dev: true - /ts-node@10.9.1(@types/node@18.16.8)(typescript@5.1.6): + /ts-node@10.9.1(@types/node@18.19.31)(typescript@5.1.6): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -7397,7 +7398,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 18.16.8 + '@types/node': 18.19.31 acorn: 8.8.2 acorn-walk: 8.2.0 arg: 4.1.3 From 185c248577f6f054b15bb7799d1d71d40e54bee0 Mon Sep 17 00:00:00 2001 From: Clara van Staden Date: Thu, 30 May 2024 10:48:28 +0200 Subject: [PATCH 2/9] apply long range sync changes (#1214) --- relayer/cmd/generate_beacon_data.go | 10 +- relayer/cmd/import_beacon_state.go | 5 + relayer/relays/beacon/header/header.go | 42 +++-- relayer/relays/beacon/header/header_test.go | 10 +- relayer/relays/beacon/header/syncer/syncer.go | 172 ++++++++++++++++-- relayer/relays/beacon/state/beacon.go | 8 +- relayer/relays/beacon/state/beacon_deneb.go | 5 +- .../beacon/state/beacon_deneb_encoding.go | 2 +- .../relays/beacon/state/beacon_encoding.go | 2 +- .../test/scripts/force-beacon-checkpoint.sh | 12 ++ web/packages/test/scripts/start-polkadot.sh | 46 +++++ 11 files changed, 277 insertions(+), 37 deletions(-) create mode 100755 web/packages/test/scripts/force-beacon-checkpoint.sh create mode 100755 web/packages/test/scripts/start-polkadot.sh diff --git a/relayer/cmd/generate_beacon_data.go b/relayer/cmd/generate_beacon_data.go index 1635303adb..dfa13e860a 100644 --- a/relayer/cmd/generate_beacon_data.go +++ b/relayer/cmd/generate_beacon_data.go @@ -55,6 +55,7 @@ func generateBeaconCheckpointCmd() *cobra.Command { } cmd.Flags().String("config", "/tmp/snowbridge/beacon-relay.json", "Path to the beacon relay config") + cmd.Flags().Uint64("finalized-slot", 0, "Optional finalized slot to create checkpoint at") cmd.Flags().Bool("export-json", false, "Export Json") return cmd @@ -117,6 +118,7 @@ func generateBeaconCheckpoint(cmd *cobra.Command, _ []string) error { if err != nil { return err } + finalizedSlot, _ := cmd.Flags().GetUint64("finalized-slot") viper.SetConfigFile(config) @@ -138,7 +140,13 @@ func generateBeaconCheckpoint(cmd *cobra.Command, _ []string) error { client := api.NewBeaconClient(conf.Source.Beacon.Endpoint, conf.Source.Beacon.StateEndpoint) s := syncer.New(client, &store, p) - checkPointScale, err := s.GetCheckpoint() + var checkPointScale scale.BeaconCheckpoint + if finalizedSlot == 0 { + checkPointScale, err = s.GetCheckpoint() + } else { + checkPointScale, err = s.GetCheckpointAtSlot(finalizedSlot) + } + if err != nil { return fmt.Errorf("get initial sync: %w", err) } diff --git a/relayer/cmd/import_beacon_state.go b/relayer/cmd/import_beacon_state.go index 8884b72f3e..f683e48db0 100644 --- a/relayer/cmd/import_beacon_state.go +++ b/relayer/cmd/import_beacon_state.go @@ -106,6 +106,11 @@ func importBeaconState(cmd *cobra.Command, _ []string) error { attestedSlot := attestedState.GetSlot() finalizedSlot := finalizedState.GetSlot() + err = syncer.ValidatePair(finalizedSlot, attestedSlot, attestedState) + if err != nil { + return fmt.Errorf("state pair validation failed: %w", err) + } + err = store.WriteEntry(attestedSlot, finalizedSlot, attestedData, finalizedData) if err != nil { return fmt.Errorf("write beacon store entry: %w", err) diff --git a/relayer/relays/beacon/header/header.go b/relayer/relays/beacon/header/header.go index 950d2cdcbc..167836bae7 100644 --- a/relayer/relays/beacon/header/header.go +++ b/relayer/relays/beacon/header/header.go @@ -23,6 +23,7 @@ import ( var ErrFinalizedHeaderUnchanged = errors.New("finalized header unchanged") var ErrFinalizedHeaderNotImported = errors.New("finalized header not imported") +var ErrInterimHeaderNotImported = errors.New("interim finalized header not imported") var ErrSyncCommitteeNotImported = errors.New("sync committee not imported") var ErrSyncCommitteeLatency = errors.New("sync committee latency found") var ErrExecutionHeaderNotImported = errors.New("execution header not imported") @@ -63,6 +64,7 @@ func (h *Header) Sync(ctx context.Context, eg *errgroup.Group) error { // Special handling here for the initial checkpoint to sync the next sync committee which is not included in initial // checkpoint. if h.isInitialSyncPeriod() { + log.Info("syncing next sync committee for initial checkpoint") err = h.SyncCommitteePeriodUpdate(ctx, latestSyncedPeriod) if err != nil { return fmt.Errorf("sync next committee for initial sync period: %w", err) @@ -128,16 +130,27 @@ func (h *Header) SyncCommitteePeriodUpdate(ctx context.Context, period uint64) e // finalized header if uint64(update.Payload.FinalizedHeader.Slot) > h.cache.Finalized.LastSyncedSlot { diff := uint64(update.Payload.FinalizedHeader.Slot) - h.cache.Finalized.LastSyncedSlot - log.WithFields(log.Fields{"diff": diff, "last_finalized_slot": h.cache.Finalized.LastSyncedSlot, "new_finalized_slot": uint64(update.Payload.FinalizedHeader.Slot)}).Info("checking max latency") - if diff > h.protocol.Settings.SlotsInEpoch*h.protocol.Settings.EpochsPerSyncCommitteePeriod { - log.Info("syncing an interim update") - err = h.syncInterimFinalizedUpdate(ctx, h.cache.Finalized.LastSyncedSlot, uint64(update.Payload.FinalizedHeader.Slot)) + minSlot := h.cache.Finalized.LastSyncedSlot + for diff > h.protocol.Settings.SlotsInEpoch*h.protocol.Settings.EpochsPerSyncCommitteePeriod { + log.WithFields(log.Fields{ + "diff": diff, + "last_finalized_slot": h.cache.Finalized.LastSyncedSlot, + "new_finalized_slot": uint64(update.Payload.FinalizedHeader.Slot), + }).Info("interim update required") + + interimUpdate, err := h.syncInterimFinalizedUpdate(ctx, minSlot, uint64(update.Payload.FinalizedHeader.Slot)) if err != nil { return fmt.Errorf("sync interim finalized header update: %w", err) } + + diff = uint64(update.Payload.FinalizedHeader.Slot) - uint64(interimUpdate.Payload.FinalizedHeader.Slot) + minSlot = uint64(update.Payload.FinalizedHeader.Slot) + h.protocol.Settings.SlotsInEpoch + log.WithFields(log.Fields{ + "new_diff": diff, + "interim_finalized_slot": uint64(interimUpdate.Payload.FinalizedHeader.Slot), + "new_finalized_slot": uint64(update.Payload.FinalizedHeader.Slot), + }).Info("interim update synced successfully") } - } else { - log.Info("interim update not required") } log.WithFields(log.Fields{ @@ -243,24 +256,29 @@ func (h *Header) SyncHeaders(ctx context.Context) error { return nil } -func (h *Header) syncInterimFinalizedUpdate(ctx context.Context, lastSyncedSlot, newCheckpointSlot uint64) error { +func (h *Header) syncInterimFinalizedUpdate(ctx context.Context, lastSyncedSlot, newCheckpointSlot uint64) (scale.Update, error) { + currentPeriod := h.protocol.ComputeSyncPeriodAtSlot(lastSyncedSlot) + // Calculate the range that the interim finalized header update may be in minSlot := newCheckpointSlot - h.protocol.SlotsPerHistoricalRoot - maxSlot := lastSyncedSlot + h.protocol.SlotsPerHistoricalRoot + maxSlot := ((currentPeriod + 1) * h.protocol.SlotsPerHistoricalRoot) - h.protocol.Settings.SlotsInEpoch // just before the new sync committee boundary finalizedUpdate, err := h.syncer.GetFinalizedUpdateAtAttestedSlot(minSlot, maxSlot, false) if err != nil { - return fmt.Errorf("get interim checkpoint to update chain (last synced slot %d, new slot: %d): %w", lastSyncedSlot, newCheckpointSlot, err) + return scale.Update{}, fmt.Errorf("get interim checkpoint to update chain (last synced slot %d, new slot: %d): %w", lastSyncedSlot, newCheckpointSlot, err) } log.WithField("slot", finalizedUpdate.Payload.FinalizedHeader.Slot).Info("syncing an interim update to on-chain") err = h.updateFinalizedHeaderOnchain(ctx, finalizedUpdate) - if err != nil { - return fmt.Errorf("update interim finalized header on-chain: %w", err) + switch { + case errors.Is(err, ErrFinalizedHeaderNotImported): + return scale.Update{}, ErrInterimHeaderNotImported + case err != nil: + return scale.Update{}, fmt.Errorf("update interim finalized header on-chain: %w", err) } - return nil + return finalizedUpdate, nil } func (h *Header) syncLaggingSyncCommitteePeriods(ctx context.Context, latestSyncedPeriod, currentSyncPeriod uint64) error { diff --git a/relayer/relays/beacon/header/header_test.go b/relayer/relays/beacon/header/header_test.go index 03b20db334..210fa16f69 100644 --- a/relayer/relays/beacon/header/header_test.go +++ b/relayer/relays/beacon/header/header_test.go @@ -68,7 +68,7 @@ func TestSyncInterimFinalizedUpdate_WithDataFromAPI(t *testing.T) { ) // Find a checkpoint for a slot that is just out of the on-chain synced finalized header block roots range - err = h.syncInterimFinalizedUpdate(context.Background(), 4563072, 4571360) + _, err = h.syncInterimFinalizedUpdate(context.Background(), 4563072, 4571360) require.NoError(t, err) } @@ -131,7 +131,7 @@ func TestSyncInterimFinalizedUpdate_WithDataFromStore(t *testing.T) { ) // Find a checkpoint for a slot that is just out of the on-chain synced finalized header block roots range - err = h.syncInterimFinalizedUpdate(context.Background(), 4563072, 4571360) + _, err = h.syncInterimFinalizedUpdate(context.Background(), 4563072, 4571360) require.NoError(t, err) } @@ -196,7 +196,7 @@ func TestSyncInterimFinalizedUpdate_WithDataFromStoreWithDifferentBlocks(t *test ) // Find a checkpoint for a slot that is just out of the on-chain synced finalized header block roots range - err = h.syncInterimFinalizedUpdate(context.Background(), 4563072, 4571360) + _, err = h.syncInterimFinalizedUpdate(context.Background(), 4563072, 4571360) require.NoError(t, err) } @@ -241,7 +241,7 @@ func TestSyncInterimFinalizedUpdate_BeaconStateNotAvailableInAPIAndStore(t *test ) // Find a checkpoint for a slot that is just out of the on-chain synced finalized header block roots range - err = h.syncInterimFinalizedUpdate(context.Background(), 4570722, 4578922) + _, err = h.syncInterimFinalizedUpdate(context.Background(), 4570722, 4578922) require.Error(t, err) } @@ -279,6 +279,6 @@ func TestSyncInterimFinalizedUpdate_NoValidBlocksFound(t *testing.T) { ) // Find a checkpoint for a slot that is just out of the on-chain synced finalized header block roots range - err = h.syncInterimFinalizedUpdate(context.Background(), 4570722, 4578922) + _, err = h.syncInterimFinalizedUpdate(context.Background(), 4570722, 4578922) require.Errorf(t, err, "cannot find blocks at boundaries") } diff --git a/relayer/relays/beacon/header/syncer/syncer.go b/relayer/relays/beacon/header/syncer/syncer.go index 3cc25c40a0..2254b0ec46 100644 --- a/relayer/relays/beacon/header/syncer/syncer.go +++ b/relayer/relays/beacon/header/syncer/syncer.go @@ -1,8 +1,10 @@ package syncer import ( + "encoding/json" "errors" "fmt" + "os" "strconv" "github.com/snowfork/go-substrate-rpc-client/v4/types" @@ -21,10 +23,11 @@ import ( ) const ( - BlockRootGeneralizedIndex = 37 - FinalizedCheckpointGeneralizedIndex = 105 - NextSyncCommitteeGeneralizedIndex = 55 - ExecutionPayloadGeneralizedIndex = 25 + BlockRootGeneralizedIndex = 37 + FinalizedCheckpointGeneralizedIndex = 105 + CurrentSyncCommitteeGeneralizedIndex = 54 + NextSyncCommitteeGeneralizedIndex = 55 + ExecutionPayloadGeneralizedIndex = 25 ) var ( @@ -95,6 +98,100 @@ func (s *Syncer) GetCheckpoint() (scale.BeaconCheckpoint, error) { }, nil } +func (s *Syncer) GetCheckpointFromFile(file string) (scale.BeaconCheckpoint, error) { + type CheckPointResponse struct { + Header api.BeaconHeader `json:"header"` + CurrentSyncCommittee api.SyncCommitteeResponse `json:"current_sync_committee"` + CurrentSyncCommitteeBranch []string `json:"current_sync_committee_branch"` + ValidatorsRoot string `json:"validators_root"` + BlockRootsRoot string `json:"block_roots_root"` + BlockRootsRootBranch []string `json:"block_roots_branch"` + } + var response CheckPointResponse + + byteValue, err := os.ReadFile(file) + if err != nil { + return scale.BeaconCheckpoint{}, err + } + + err = json.Unmarshal(byteValue, &response) + if err != nil { + return scale.BeaconCheckpoint{}, err + } + + header, err := response.Header.ToScale() + if err != nil { + return scale.BeaconCheckpoint{}, err + } + + currentSyncCommittee, err := response.CurrentSyncCommittee.ToScale() + if err != nil { + return scale.BeaconCheckpoint{}, err + } + + return scale.BeaconCheckpoint{ + Header: header, + CurrentSyncCommittee: currentSyncCommittee, + CurrentSyncCommitteeBranch: util.ProofBranchToScale(response.CurrentSyncCommitteeBranch), + ValidatorsRoot: types.H256(common.HexToHash(response.ValidatorsRoot)), + BlockRootsRoot: types.H256(common.HexToHash(response.BlockRootsRoot)), + BlockRootsBranch: util.ProofBranchToScale(response.BlockRootsRootBranch), + }, nil +} + +func (s *Syncer) GetCheckpointAtSlot(slot uint64) (scale.BeaconCheckpoint, error) { + checkpoint, err := s.GetFinalizedUpdateAtAttestedSlot(slot, slot, false) + if err != nil { + return scale.BeaconCheckpoint{}, fmt.Errorf("get finalized update at slot: %w", err) + } + + genesis, err := s.Client.GetGenesis() + if err != nil { + return scale.BeaconCheckpoint{}, fmt.Errorf("get genesis: %w", err) + } + + finalizedState, err := s.getBeaconStateAtSlot(slot) + + blockRootsProof, err := s.GetBlockRootsFromState(finalizedState) + if err != nil { + return scale.BeaconCheckpoint{}, fmt.Errorf("fetch block roots: %w", err) + } + + syncCommittee := finalizedState.GetCurrentSyncCommittee() + if err != nil { + return scale.BeaconCheckpoint{}, fmt.Errorf("convert sync committee to scale: %w", err) + } + + stateTree, err := finalizedState.GetTree() + if err != nil { + return scale.BeaconCheckpoint{}, fmt.Errorf("get state tree: %w", err) + } + + _ = stateTree.Hash() // necessary to populate the proof tree values + + proof, err := stateTree.Prove(CurrentSyncCommitteeGeneralizedIndex) + if err != nil { + return scale.BeaconCheckpoint{}, fmt.Errorf("get block roof proof: %w", err) + } + + pubkeys, err := util.ByteArrayToPublicKeyArray(syncCommittee.PubKeys) + if err != nil { + return scale.BeaconCheckpoint{}, fmt.Errorf("bytes to pubkey array: %w", err) + } + + return scale.BeaconCheckpoint{ + Header: checkpoint.Payload.FinalizedHeader, + CurrentSyncCommittee: scale.SyncCommittee{ + Pubkeys: pubkeys, + AggregatePubkey: syncCommittee.AggregatePubKey, + }, + CurrentSyncCommitteeBranch: util.BytesBranchToScale(proof.Hashes), + ValidatorsRoot: types.H256(genesis.ValidatorsRoot), + BlockRootsRoot: blockRootsProof.Leaf, + BlockRootsBranch: blockRootsProof.Proof, + }, nil +} + // GetSyncCommitteePeriodUpdate fetches a sync committee update from the light client API endpoint. If it fails // (typically because it cannot download the finalized header beacon state because the slot does not fall on a 32 // slot interval, due to a missed block), it will construct an update manually from data download from the beacon @@ -568,6 +665,37 @@ func (s *Syncer) findValidUpdatePair(slot uint64) (uint64, uint64, error) { return finalizedHeader.Slot, attestedHeader.Slot, nil } +func (s *Syncer) ValidatePair(finalizedSlot, attestedSlot uint64, attestedState state.BeaconState) error { + finalizedCheckpoint := attestedState.GetFinalizedCheckpoint() + finalizedHeader, err := s.Client.GetHeaderByBlockRoot(common.BytesToHash(finalizedCheckpoint.Root)) + if err != nil { + return fmt.Errorf("unable to download finalized header from attested state") + } + + if finalizedHeader.Slot != finalizedSlot { + return fmt.Errorf("finalized state in attested state does not match provided finalized state, attested state finalized slot: %d, finalized slot provided: %d", finalizedHeader.Slot, finalizedSlot) + } + + nextHeader, err := s.FindBeaconHeaderWithBlockIncluded(attestedSlot + 1) + if err != nil { + return fmt.Errorf("get sync aggregate header: %d err: %w", attestedSlot+1, err) + } + nextBlock, err := s.Client.GetBeaconBlockBySlot(nextHeader.Slot) + if err != nil { + return fmt.Errorf("get sync aggregate block: %d err: %w", nextHeader.Slot, err) + } + + superMajority, err := s.protocol.SyncCommitteeSuperMajority(nextBlock.Data.Message.Body.SyncAggregate.SyncCommitteeBits) + if err != nil { + return fmt.Errorf("compute sync committee supermajority: %d err: %w", nextHeader.Slot, err) + } + if !superMajority { + return fmt.Errorf("sync committee at slot not supermajority: %d", nextHeader.Slot) + } + + return nil +} + func (s *Syncer) GetFinalizedUpdateWithSyncCommittee(syncCommitteePeriod uint64) (scale.Update, error) { minSlot := syncCommitteePeriod * s.protocol.SlotsPerHistoricalRoot maxSlot := ((syncCommitteePeriod + 1) * s.protocol.SlotsPerHistoricalRoot) - s.protocol.Settings.SlotsInEpoch // just before the new sync committee boundary @@ -591,17 +719,31 @@ func (s *Syncer) GetFinalizedUpdateAtAttestedSlot(minSlot, maxSlot uint64, fetch // Try getting beacon data from the API first data, err := s.getBeaconDataFromClient(attestedSlot) if err != nil { - log.WithFields(log.Fields{"minSlot": minSlot, "maxSlot": maxSlot}).Info("attempting to find in beacon store") // If it fails, using the beacon store and look for a relevant finalized update - data, err = s.getBestMatchBeaconDataFromStore(minSlot, maxSlot) - if err != nil { - return update, fmt.Errorf("fetch beacon data from api and data store failure: %w", err) - } + for { + if minSlot > maxSlot { + return update, fmt.Errorf("find beacon state store options exhausted: %w", err) + } + + data, err = s.getBestMatchBeaconDataFromStore(minSlot, maxSlot) + if err != nil { + return update, fmt.Errorf("fetch beacon data from api and data store failure: %w", err) + } - // The datastore may not have found the attested slot we wanted, but provided another valid one - attestedSlot = data.AttestedSlot + err = s.ValidatePair(data.FinalizedHeader.Slot, data.AttestedSlot, data.AttestedState) + if err != nil { + minSlot = data.FinalizedHeader.Slot + 1 + log.WithError(err).WithField("minSlot", minSlot).Warn("pair retrieved from database invalid") + continue + } + + // The datastore may not have found the attested slot we wanted, but provided another valid one + attestedSlot = data.AttestedSlot + break + } } + log.WithFields(log.Fields{"finalizedSlot": data.FinalizedHeader.Slot, "attestedSlot": data.AttestedSlot}).Info("found slot pair for finalized update") // Finalized header proof stateTree, err := data.AttestedState.GetTree() if err != nil { @@ -620,7 +762,7 @@ func (s *Syncer) GetFinalizedUpdateAtAttestedSlot(minSlot, maxSlot uint64, fetch return update, fmt.Errorf("get finalized header proof: %w", err) } - nextSyncCommittee := data.AttestedState.GetSyncSyncCommittee() + nextSyncCommittee := data.AttestedState.GetNextSyncCommittee() syncCommitteePubKeys, err := util.ByteArrayToPublicKeyArray(nextSyncCommittee.PubKeys) nextSyncCommitteeScale = scale.OptionNextSyncCommitteeUpdatePayload{ @@ -789,18 +931,20 @@ func (s *Syncer) getBestMatchBeaconDataFromStore(minSlot, maxSlot uint64) (final return response, fmt.Errorf("fetch header: %w", err) } + if response.FinalizedHeader.Slot != response.FinalizedState.GetSlot() { + return response, fmt.Errorf("finalized slot in state does not match attested finalized state: %w", err) + } + return response, nil } func (s *Syncer) getBeaconState(slot uint64) ([]byte, error) { data, err := s.Client.GetBeaconState(strconv.FormatUint(slot, 10)) if err != nil { - log.WithFields(log.Fields{"slot": slot, "err": err}).Warn("unable to download ssz state from api, trying store") data, err = s.store.GetBeaconStateData(slot) if err != nil { return nil, fmt.Errorf("fetch beacon state from store: %w", err) } - log.WithField("slot", slot).Info("found state in store") } return data, nil } diff --git a/relayer/relays/beacon/state/beacon.go b/relayer/relays/beacon/state/beacon.go index ef0ff75393..bd0f5e8907 100644 --- a/relayer/relays/beacon/state/beacon.go +++ b/relayer/relays/beacon/state/beacon.go @@ -164,7 +164,8 @@ type BeaconState interface { GetBlockRoots() [][]byte GetTree() (*ssz.Node, error) GetFinalizedCheckpoint() *Checkpoint - GetSyncSyncCommittee() *SyncCommittee + GetCurrentSyncCommittee() *SyncCommittee + GetNextSyncCommittee() *SyncCommittee } type SyncAggregate interface { @@ -318,6 +319,9 @@ func (b *BeaconStateCapellaMainnet) GetFinalizedCheckpoint() *Checkpoint { return b.FinalizedCheckpoint } -func (b *BeaconStateCapellaMainnet) GetSyncSyncCommittee() *SyncCommittee { +func (b *BeaconStateCapellaMainnet) GetNextSyncCommittee() *SyncCommittee { return b.NextSyncCommittee } +func (b *BeaconStateCapellaMainnet) GetCurrentSyncCommittee() *SyncCommittee { + return b.CurrentSyncCommittee +} diff --git a/relayer/relays/beacon/state/beacon_deneb.go b/relayer/relays/beacon/state/beacon_deneb.go index bd889c8b18..f68f43ccfe 100644 --- a/relayer/relays/beacon/state/beacon_deneb.go +++ b/relayer/relays/beacon/state/beacon_deneb.go @@ -134,6 +134,9 @@ func (b *BeaconStateDenebMainnet) GetFinalizedCheckpoint() *Checkpoint { return b.FinalizedCheckpoint } -func (b *BeaconStateDenebMainnet) GetSyncSyncCommittee() *SyncCommittee { +func (b *BeaconStateDenebMainnet) GetNextSyncCommittee() *SyncCommittee { return b.NextSyncCommittee } +func (b *BeaconStateDenebMainnet) GetCurrentSyncCommittee() *SyncCommittee { + return b.CurrentSyncCommittee +} diff --git a/relayer/relays/beacon/state/beacon_deneb_encoding.go b/relayer/relays/beacon/state/beacon_deneb_encoding.go index 2ad6ca4f25..f041c55b0f 100644 --- a/relayer/relays/beacon/state/beacon_deneb_encoding.go +++ b/relayer/relays/beacon/state/beacon_deneb_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: 2d1815cffaa3bda65721acc72bdfc0e47fdeb4193ba7500d237e58f2369c3628 +// Hash: 03b5096ab94e41e2c740924a4ae7ea8fdd515fe3dd4861032a569e28bcba8bb4 // Version: 0.1.3 package state diff --git a/relayer/relays/beacon/state/beacon_encoding.go b/relayer/relays/beacon/state/beacon_encoding.go index ce50a6e58e..ea9e3b1fb0 100644 --- a/relayer/relays/beacon/state/beacon_encoding.go +++ b/relayer/relays/beacon/state/beacon_encoding.go @@ -1,5 +1,5 @@ // Code generated by fastssz. DO NOT EDIT. -// Hash: 2d1815cffaa3bda65721acc72bdfc0e47fdeb4193ba7500d237e58f2369c3628 +// Hash: 03b5096ab94e41e2c740924a4ae7ea8fdd515fe3dd4861032a569e28bcba8bb4 // Version: 0.1.3 package state diff --git a/web/packages/test/scripts/force-beacon-checkpoint.sh b/web/packages/test/scripts/force-beacon-checkpoint.sh new file mode 100755 index 0000000000..bd5cd1364c --- /dev/null +++ b/web/packages/test/scripts/force-beacon-checkpoint.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +source scripts/set-env.sh +source scripts/xcm-helper.sh + +pushd $root_dir +check_point_hex=$($relay_bin generate-beacon-checkpoint --finalized-slot 9043968 --config /opt/config/beacon-relay.json --export-json) +popd +transact_call="0x5200"$check_point_hex +send_governance_transact_from_relaychain $BRIDGE_HUB_PARAID "$transact_call" 180000000000 900000 diff --git a/web/packages/test/scripts/start-polkadot.sh b/web/packages/test/scripts/start-polkadot.sh new file mode 100755 index 0000000000..2028e6514b --- /dev/null +++ b/web/packages/test/scripts/start-polkadot.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -eu + +start=$(date +%s) + +from_start_services=true + +source scripts/set-env.sh +source scripts/build-binary.sh + +trap kill_all SIGINT SIGTERM EXIT +cleanup + +# 0. check required tools +echo "Check building tools" +check_tool + +# 1. install binary if required +echo "Installing binaries if required" +build_binaries +build_relayer + +# 2. start polkadot +echo "Starting polkadot nodes" +source scripts/deploy-polkadot.sh +deploy_polkadot + +# 4. generate beefy checkpoint +echo "Generate beefy checkpoint" +source scripts/generate-beefy-checkpoint.sh +generate_beefy_checkpoint + +# 6. config substrate +echo "Config Substrate" +source scripts/configure-substrate.sh +configure_substrate + +echo "Prod testnet has been initialized" + +end=$(date +%s) +runtime=$((end - start)) +minutes=$(((runtime % 3600) / 60)) +seconds=$(((runtime % 3600) % 60)) +echo "Took $minutes minutes $seconds seconds" + +wait From 9c35d51c364ae5027e24600b07c2200ed3fcba88 Mon Sep 17 00:00:00 2001 From: Clara van Staden Date: Thu, 30 May 2024 09:16:06 +0000 Subject: [PATCH 3/9] GITBOOK-81: Chopsticks --- docs/.gitbook/assets/image.png | Bin 0 -> 79287 bytes docs/SUMMARY.md | 1 + docs/runbooks/test-runtime-upgrades.md | 191 +++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 docs/.gitbook/assets/image.png create mode 100644 docs/runbooks/test-runtime-upgrades.md diff --git a/docs/.gitbook/assets/image.png b/docs/.gitbook/assets/image.png new file mode 100644 index 0000000000000000000000000000000000000000..67ef7a1b01b0d17a2980d00485e16a6c0246982f GIT binary patch literal 79287 zcmdpeWmsHWlQk~EH3Zkjo#5{7?k>UIA-KCX9y|#U+})iZ!CjN!5?sEMd1vN%XYRe3 z|KE?M58az1dso%2TD2BY%8F7bukc?%KtQ0#NQRymXx$rp#8ZAv!7RmDXe*TNDLstbP^8GXHR`|2$h7;=$kD$$wu<7^+W7*uwNydnM)1 z_fCg3`G+U_eZFwuw)@X&8ma$z0)7uu2jxKj+vWe}5SaurEo#fmUg>YX`?t9*m*3it;DDc=Xs$G>OmNnb#= z{WgRq(hp-}=;$DMSR6`cHd0JD^%~-9T=zLN zIc)tE-}$h1>f!FL67Z$tdf6m`NcRi3tZd!WdcpMPs@8XIbXb88D;vb#yBLzU z-Oy;rdjn`%dSmH~ZO4r(g-wat;hzt_eVdx#+VI=(znQ)dCva$Py{|U-n}=McbXqX* ze0sj=>bjidBk?=yao!mu3b@_MwyTxNwyzl64t&1MXYRZ;<;~=F-qu^cNGCh8Io8AB z4Rs3tK$|XT)}U8!J#D7tKN$OidViuQLGb>p4~<=pS}8BqIjV(sQ>03}X8ZZ!^uTv8 z_KjMZ;&D?h1|c1%_K^({5fQTc(PGULDo!40mZ1Or{o$7`U_#$Ace(7z;vf9vT(&Mk z!~$_Jk1u(^&pL)2N(Ti^&AG!U>t*n`?#mnEn%o4As^!gSu6*-oLp#m`Y$m4#F_h@4?CqM)m;%&PBchgFXApwY|y0z)iY&CBzH z(9WBw;T$&BV6l#!c)o!9*?e-seXqP<6;>U$1I#B!N8>qc=R-oz=U1~zR^3U6BdUPQp4|ci3PMod70WjAW!lZd{PqGDe9~DW_c@i}A1iis6BRmj z;k7?FS9Gh5MZ6r(xEpl7ZNErDxO2Qocf1Rv-|acWJS#T-zpaw7#mRttY(@aJuwp<*0KJ3?nT+j75uJzk0 z&yLUJicUXocM_yT$9j{x(UtZwqzI>d5Uy4nyPgh>UVIoDC+BgQ4gF?hsL3D-l*}zD zC7sGciSPEgx^&)5;b`dxmG)9ymTM|L0iW<3`Bwx}R@#F=KLi}|!t;1j}gW0h53fSRJ0?)P{fFILR zBwJm24lR50dX5sy1Joz^FOSr0%r~9WDLl6($a+W!$$$}^4v%8?dDM9P?Y1*72$gAl z+H#QB%rqYxxX+&NeWVcc>zkGqUHqK#x!(PC zQx{PP&*pn*!CxPhh`e``fEg6v2_=1)P6$wcLp>@oj7fv*=rfxw_^>q9c_45aW&P7j zBDIrN<@{meC14m6FwimJnV&|K0$jSDu6MAV62<6lYSwzx2lvTfG-IYA z7gAVd8F3lBR#&&&@TmzbDMQG?Ly7Zs0occB?tG zDsoVnGoTNJ>+$I{P)fn1>e4n;c@TA`^N0%M-X*G=v$YHWM;|*Ya$U&}u30cTdiAMGm)TN)@sP z*SsfP^Tec^mpeD7%A5Uq4}M);LBK<~?vF2d?Zg$aVBC8RWK?CXN_}#=wqW+v8O$`p zPZ?<>Uu9TVhJo;#Ny+QCuRIhP?DqQ57xb|gyHLB092bfh3%XdIk{I2X?E>(T76(s+ zfUn(piMBy-(}R{%c_?>}A7mNYJ-)PG$OcShuB@6$4n+Kzs2F2Q+Vr8tT|gqtvVdqi zX|y>|#U zxGhGpkErqbSrZZyK89v0+BEi_xo?HNmh_HQKMlBP<63i5+drl)%L&eay9XNbA%`)Fo7HIAOt);bAP+1-!cX^DyEn8`hKD7c{cx< zqoO+4)SDhhT$1>8JVTH|EQj63dAz6J;|>Y6|MilUnX~LAD1)%+JzE5!i?n(%#rSX+ zu<;@&LQj3?P;B7KEKl25?U7Hx6AJLdCm4H_%^K;Z3k(mP5AN?(mV4lF64m)QM~4R1 z+S?i~jnD>vKl$-Px6}0Q0>{LBcNxg5*#ijez#Ti%2}JCcJ}DeznPc>$Ft2}o`x5Zhu0Pe>vS`%U+)Au$#$_Vr$Tj~#zdt;h%w+Un z8PP0aJX6UrzA<@|R)S$fzB$aR+pD>72E!$hXd2d0RnWqyT{!^A8Zf(n!de!VS4<$AABpmGVVwr*649`}`~OBe?d?x_Gd64ATr^#>@MQ2!cPst}&D z0p@_DGG_Sqqruk@DEhE>CS!h8H+&m+sM+OKiX3D0fp=p@hR@6A>9J8m`k!d9Xy}y- zVGUn>luvwP&nBl*=h~@)xrZ#&-_-SVjBUMFy1OX<*D`pSs@tglwZ4-`)on2^Tq4Ua z%-Qg+QhIu2R%~Ort?ZKMJbUC zlL{7LMne<4-DDv#%jsY56@BJ*|51`r`<-WI(SAlaG#!1O;K=Ta*8qJr!T@coOGzbY zg&8Zg-wl^%(U{dc460|>B+b54kgR)j`Yn@*HU!{1m{h zc5ump$saEH9d|XV)tM~O;XG^$A7Z8RnNy+pQ@}(^LxZN; zWFjq=mo$Ukd{~M&a($7~!H>+ZK((pg*D(#|8g~~gArBSrakKg+R4NmBx%I(xFg1Mc zTYsTUT^EqFsHPj&Gf*mp2u_clRhdrvr2O>$8H%Mi>;|gt+Yat-1G#wZ%juA_fuE@J zyi7IUB*W|`&FwDHXO< z(}(M5NRG1OxP$6(!O{pqw>@aJ!k|nc1qe%4%=WqfAh=}kfcKNgHi@8AIS0yRb;#wC zH+!ea7SX9*U1sg$9wb=kBtQJ#IPUsLo}HRodgA10ETU>z6w&yL$$qBZ9ha)q*_KiH zkO?tzU)8T^s zYSL(;WE!FfWlV|Q)MAK!iAGtBTR~WF72P#$f2k3niwOCfpn!}Q$xHc#F zbn5#QHcoiaP_CFMDz4e@q;*ts5kqo0vrT)Y_;!nb=cEIM{g=JQ*Q>5dqHep~G6e$0 z?WT-x*|hiB(I^z!bH-y~^3Z9piqC7LErL0C5Zu$6Ng@2M>+<6QQczSY)_TOdCc11= zxar$fo1;hBjF3Gmm=n@EEhtuvDkdC#S5`=s_rK8oAX5hUoVjSX#-1tYvzv+?6i20i z6z#~RIix~-VR>OlC|{W)f(YZK{W4+r$DcaMv$+H}>)I!ZnB>}7OI^Y82(gXhUKbcs zdg`D|6M26QNlUy}{PRDp)2X($ID3B8>iETDg}v`}+O&e6Aid0{6g-boI*TWr)LqP~ zsqVTeVJ`8jJVXrHGC?9sG9pc1m#S;GRRwono0mFG9Tm1I~>2=61VQ{zZut~m;HK_b%+q_w8GK7a^|dYx_+r2%GsfQ9vY_$w3v5fKV6O-hMsUKW@~k+Rta5ZZIV!gi8;#8% zicOR;*2T{YLM$%QqgQdI+1Y8aK*lfh+>E`Qd{~BR8IR%$0-#u;&tncYmDEd3zpHZPg)jbX-mm> z6v=oMY&BlA&nRPfyqp`fDb((YS=fUcJx0iwupmW-Wo?z|&o!49CKWX`U%pN$f4r8a&*(Y_lsbgw+06;(K@1_ zUNW84hrVd(fMysDq*P-T-XuVJ9DCu*m~ZKmPF3gMjax|yq9IeU8oEjcfkwqUsiFjM zpv|s#G}B`?TysK>BBi(n2mQAS3z@MkO$o_noT6{|eoBOPkM?3yj1y|mpcB}In!VDB zAj$`aLt#KEaiET1THd<2L~{uS7&Ctc_$rp5|>B{{}6Z3^Fk;p%4Q*;2Pnb3(ixX=rQTN=9!5!$=$HjHg4!Egx!6v zl8tM|zd~cyq7X=khuDbwBIdy~xQEzxVs9%@(pt5>STc<#`u+MpHUiPr1#D?S`G}$L zo%o3VktbhH0uf*2E8cU~Ae3-oYUSEDV&lr>55m&{FO7r}MjwF(mrAy2Q{21|%s(Qq}D%aVcRoKgt zX^@OPD|1TD@$_&BdOn1&C1a!uyL4lmL4SU&CSd2L0u-BLwO)2cOQ2*Wyn@19J5Ee+ z6e3q~QN`l6F&)Z2S*Y0$mMLajtqP}_Z#)SEV+A{!UJ)% zLz@wO1{XEh<->TiFOS!DBb35d$#2rtSf3~7<#jg9AYiJMy2a}>kosFQhlBgof*Sk! zUqwOLM=znz?|wM`tYZ6k=7c1cW2__4Xi!n}sLYCHO2_d@JKM-1tG~Zw?{;mpjCZ1c zPGWEvck?}G_ms>&vS3dvnhtqZZwC`Goh5q$c_`vp#uuKvP}R31NX+p1e3bR`+7<~? zy+Bs)&M)Wn&PjQ*je-9*UUhRXS{0h6jdUUoSodF;^4ELf+b6|s`gfRL>Qf??P{ZU< z5#f`O!#5t(^U7m3)t|>RW<(qHxq7ayQM)IoFvZbet2LzpUM7ovZYJ!)b> zK6_Zo5C(c$J$z25o!?+j$BEE}9uvBBeM<=!5s>j8_7k57gPXa^m8xQ58TTs{&x1wQ z_@uk`isa>-#Dv$l#_rJ|AvaVTzor`h0Rlsi&NnsPpuW?zw&L3<`PzJj){Na@jQAL9 z>@$at7;5c2rN+K7@fP}_b-H7f{FSXDEmE<>XFL9*pq9%~1k{EWyu??KNUI@uy#%jS zeN~*@7pYle&ZGL|&G`_6#6=xcz{8R*EEcv3GmEPb#BDXnkw}O`2=8k4n_YOmmu4Dz zkI%kaC_Y#5diQ=5W<{S_f9^FT9IfSY>If0h&d#z!^VT4bYa_wM;Vuq|hoAgnyO_5X zt1q3W=`bH70c9(Oo`N_kq~C4gAbt!6?xDxzWUcI)8-x3l+9QNHcFVAQHrr3R@4hr7$IGt8aG(%^v=0;O6L(%r*Z3Y zQzGz_nr4eL^N@m~9ZIzEA=AYuQfMi;PNtWyb+8YPS49-g*;UgECtOEbbtA03uq&tV zj#QKTX$W4T>3OsYf_@p@qQg%k6Y^%07z-qJjt(?9A8FmQ{p@m#fN6m$u>VVwDQMow z4Z)qUZIWqOK|N=`S?Y~khUaC*yYEq z<2wFgjJ_j1R+fe;l(P6bnc}-zuNFFJ*jOzk*Er}b2adR2^?O=c)?C%Jl+ycMf6D96k>{Eq$*W-;+np-D$Dh_%AvS? zcdG5Nax8Z+wB^A5JOxdPByRe=oh>tNGYVrxlhKauZ=YaOF*;rE%Ox_p+(TQ z4B^d^o8Q1siA1K!rj9F6_jmrPbgbltmp3~fSxixLnd19Nl^CyBcauS7D8l_f`C2ZU^62JjqZ+QpT3tKa zKA@9if&GSwf!!9%ZY(I<>JD0259CtYHbYVDVDgNZeAfUyrba2-%a622I|Ek3{hVrZ zUQIsSpgm^#-(AfALehh_s1wugk6M3~q@ zG3JxG&a)dpS7{{qfvY8-j`LtpoK1Ld@ULKxTqI-whemBTuBm6Q5f4t>@y0t;7*odG zTcR1{ePS?b%mG^zGQ1c0Xa_c3^3shPz3Ahg(;pw@uaNf&m$lT_x~C@{KIczwT)pVs znQ;lIMG&0ar^*PU806+@iEdi!Nv(XVbg(uhMS<)eiB9!sN12LJmbuxX!>wE z0Kj#Jv0HQSXuJ1Y$@gAKdFjB_o*vJBAz0BzQV0A8?bN<8j@fXxPHyR)ptqaUxXeLz z;4nlx^}GlR@&K061*+UZ2Xqx=$d+mPTDu4ill|>{?C&W31JQW0kH+Y?Y_KckP*Z2b z6B#ZgANd)idb{7fNTg9+PROCEGS+lmsPFchc+*ovu(G$$rH_mwqQgF>|4#@TNEIqR zz_?hr#doFQ_|~|&oQ-h+R$y{P$gb4wWi_`QLqU8;lsfccVWSB%>%)57V+ zXd`Y^x*6WnkC|Y&Hr5Nhd@D3MwJgPh0@s?l4}WT>@sb&ch4pgD_DhFX zOPwB@JI}zQWpE?$EUn&W1@re5iqp@(t81FRu?$A0cVjo%?08BsGjy*3DcKrmcw7bo{3Z2GdQ9~6kyPYw$wli8>{ zP{Q#s<5;=IyUc=;DPVZ)aKgTX;6sd`=uU~{^-rxX-Z1oV^&~2q{JKa_EWzxO^==^% zbjD_3eeRZP7vtRws+PB}jr>?E&)lE#0S$%x?{7)njFi}TQiaen^8 z@fs!OGC8IeN{Q-m-f* zuJKA2@1#hovoAG4IJb)sd(fY9xu+no7m+&Eo>Ml%Q^*l!*YzQ#*7R97@eXJs2z{g7 zRL(;(4`mf2&4(J-vC0iw5w90ZLtm%KiQ!Hr@T+Rxjy_6Crk%0`U&Nu^;R&58KNF?k zz~@-IsKKq4;`|kW387A;zc&3r30J)*s6O+%Y1^;} zaj>J^H5(nqIjoVcPiN$@38Kx$-?PCTIPkj#03&6e2;*pZ+g*AC`>-R*2ybr#ZhBtfAs8nAcB=W|MDAdsq{Oj%en6GlZ@O}vw;lJLr_mdmSy*r-qj7=oNvtaf~Zi)*PB;`h@`p|c~R}T|o zkz@l6kHDt5vw;;Ui420-l67Af_bTj{!?NEmB6<4GxndV`Y5AP2lK0hI1?l29ut@#{ zpqP0hJ>5~2K|G9P)m=COi8p@EDuJ8x@viH|ITXEKUMr|o;8%W{+x_}qo-QP)QwDVof4+J4#V zCN@&*r&&Rn*tuBp2*eSrJB9^IMCNp*8SQTYKr2|vL{hbCia7iOW&kw8;>0!3-put_ zrX7-ZbCw*Q?I)hz`u=GLX=;Z&%D@TnF!5wceO*sQ#dJCQ-s0f9OgI3TYjn zUErczOqjwj?9^4}6}RmlB7}dfW2k2zRmkkSG}ifa>}d7c zWNx1YW!3xDx1f$J3J8oTPCnQ@T1>ZApr@a~6CzJB-?7K&D1t^4$e!uZo_zcEU5zvG zr2fEErV6a`Tet%Gvvr=^%@DI(zA5|mG8g1(C^O=D&~2r}YCLu50Ub#t&B&7aGZ1HI z=J?jnQL%GxB@P_+QQ|tkVyd=@Hi|soEiZK9LWdyd6Y{?v&|^lsyR%}CCFJSqv@ZKT zHO`efbJKnxV01UCWgUTb=T}RPwR7wk=u3Q3Geo?aWEYW5aFd}$dD_#OtUXWF!*aVY zF7etxJgd``9dqW9AnXA^^yKdWPQfd&>14l*IkFDSwBfFQN$BdU7)H-Ulm(ON(?Psu?DE{+z>xmI7K6g#s(UFc0cPl={*U0gq?E>G zSxD46`8{m2hpRH72{8#z?e3a8^20PC9EIiBQd0?jUcF|vBptmH57%NsI3Y!_By9a$koMcP>x&`-WB6BrV%izKX9Yo+-5cHyG% zbw@cHt04fw_V61Pu=1}n94$;lzYj=59KGZd3HiRogO!FO7WQWA(C~)0 zl*=RouuH%7W%Z9Rc{>_%1_3V@P_!c{VfEcz#?lAyN|C^hx;Bir_bWKYlgn}Z!b3Ui zr!=?ISQyT#Co!)_>=zoW5q7>&ac>o{OyP(EZ4CIYB?mG!Lt%6i%n6*#0 z?<;EBe%oiFJ3k$Zh( zWX5;%D!eUJ4M#)(NZ1F+4<-A z&9;++@3~#lf}`U6TH5Z}-?8{37KNQHMP@&*=%&c;bJeBXvhYW&j)ACn!a~CW;WQ#E zQ%5x}>Fje+446KhJ@jKw{TwdNblXX^`Z%!e&Fvl9TowI|JKJ8MXOX2~7F!59bmm=$ zX=*XAq6u>LPX&f)Y+`e%aTwn1Av``o|F>-RL*>nB*n^khM%&tx8@sJgpr4}0w?9xf zh?Lo5nr$0kYjsAIlf?!(tFwFgB2T{4Y>X{=*$1<_21%o~3$zUGv*(~_-LlZZ#O|A! zL2w|wvlQ)mEh;l8UxQ0+f2_eVV&kHgHFuHv*9Hr)^&eS$w7~8*jCJNjGW%}+bxI1-p8E=oKVKkaAj$O-bg$lOcVl9aeqb*hb3Z*6{|q(Sbk4)W@L z=Grvx)+iek_D&}?ibdHCNtr!iP>0i(qb`iF?a}Vqg7y6(4`@F6DRj*)ZkqgS^x20D%w~ zY+<^9a^ux&cJBuQNIC;IXb!xkC+V23Tq{;n6E`KobXdiA95d9dt*yO05FMXSltk4@ zo;KQ*Z}(!15Rg}C(yf>AV!X>PechdMM|M)M2s`#2WVezEg_cg!t zGvLv;lIg-Zq$+nDs-Qw`1OMO*3}$EI%zy*ak}uO9!?xsHkze>YcVGD>{mf%i`$rbL zung&O+%0aZuWW{eyqs}4LVdYB2a;iZVH<%C)M*~A8ZQ9~{SLRrt0PV`rV|VckaG?y z*xMQ`<}*K6i9tpOrj=8_xI|xI=Z~TjX(q?T>cz-sXaRv1)mm4BTH0aeSQ<$+(`mq& ze~s<`2{vS*M;_DAbeEPXM0?9Z^~n@0p)QW4yR)VO&A&L>YcVGHh(2*|Z5awhySLu+ z0N|RUWI8qpPJ?omo&!C%-BxuPOj;?dw4VCD+wklnxk42DAtW-)ehuzEpy#K)uM~WT zL<(Eeay7|!3Q!ukunJ!>^Xu0*twk$cet#*tE+owSI~)4VaAzThiJTVi63_a7F$T(?{5l3?i07+Vtp|MqYfw=t&ywrnbGH?g zvnfE8HK?oZA;lXOA1r?D_HdJym%ITgt-mK?6@x&)VzZUBwP-57O6No z!F3rMfr#KVQ?UT;Br0o?G;)tJng@KL0Dk8jyvLamxiP=lf0&W~)EvaZL7}8ZojAa8 znTSdqqfe8)V?Wi$S^CHsh~O&g7-e_eIM8IyiTpY8SVXZe}&6*`_4AGRN8m{6#*^~T+P#KV<*TW!d!)N&wlfW!9Z zJeZvl5$I}k&s&*F8!t!xlllL)6k`O$IW>^aLuyow*QCi=pkOqo=}nsWQ=5;w&#k4&Jb0l^g5_`LDO5 zY4mkSSpTQZxbMO@c3GO64bAaL2$G9itm?T>_Gc6(FFl^+3wTNpmhBIW)XybR-(f%hUX;L=^=n@5%Son3HtTDm?dy`u^C01r zg7F@9v`W3&@d_{Xqs5lX-Ws&@ZE=Hzh+5cQfe&KiMsBY8Ap@WMX$_l>^~Rtqe8%Z? zs3_CfHtPShD8f(_a>gAcF|Rcx&!!$usI5}sI4gG1E~=ReW`n((nA_}A&a>;L`hiV? zsq?Ts@y))2p%Bfiu}?c7d?G2)lQN zR*6*Xn#O0GE?D3_XwiYS;50N(lY4#`VqL|XqKfnz%$%Ftrd3qQ*Ebo zuF6DNj&CtOaQoyP;OOX>V)^j_An?C^x7E1f&;}?U@N?~114B?a3or(&jeCLt!B@Eh zV`v24E&$grw!#8RoopkCHC;>8OkpAms1U*Qr*$dfkG4z97EwoOD3rQO@O)!+-}-^J ziOOrXvJDtsar-y=G?w;l?)J}sr;QNudDtD(y8e5h|DPL5g#}D?@Vc+ z|MVPV15;x);0 zuCmZqbpLIif0%9oH(ujWwyWP5C5D$P_0$_cu0f_W& zwqq5^XHdmu96#RA8B$_wTsd4-wbzjE8|L)v8@|2Q3e7O67JSI_Js~^z&As1YRgp`2~-jWJm0=2l1VV;u#_qu zvs({YE!A4E9v$&s{P3SHl&G#eehU12{}M={tOUkecn$NhtZa(F*ji{y-T8C|zHW(Z zfwFMV2}I-RIYRuPaE2VQE-Z5$y%hNJL~xvyte~;oU=aWwW^*U00|LnD@6N)Q75-E# zfN_U#TYvGMu$8rnv15^&SE$#na4=y1YrEuMlN9te7~*riPC=}Yc*=CR&$Q1R$Rkeh zNNj?KmAq(6i%WpkX8tiav zWxHDaB9P41Y?~rUD3*VNTbu~~iS1yJ)^i@b{)XBCcgky!z1Gu@ua~5RT$F`4712C) zdA+VGDjD#4)5m5(t&qGle+PKm@6@SLxFHN0RY^{E;J&(1wVJG)?Aq7Fr>Rt>^59wk z7AJ3J{T21f^Xb<@g!}~nz9TwcK$F}GSFZNXw#_Q?S=gq9qRQfs68{yA{8vWANYNc^ z*;{pWIEp?B19ERH9_)>$%AI&0MLiW!eT@Uk)Mjo!>qS-+8_x_wdcDmP^y_6tj(H7m zMfw2bw4W1bOSt5^k}lsp4ZJQ(w;ATu?LIM_4&p%bd=PhjdAgA@k|wR>NVC`toeBQ0 z{{_$;Yz4Pr<=}_moVdmj-!))pH z(3cVt+eCMqg)vu{`_TMTnovOj!*Y_*cq^$D_bpY;q;rZb^Fjs-`rTv>4_Ib9S&j*j zDkPU+QJ7qj%;phLXNlwp*OMX#(C4PxTT(Qwx**w@Dy0P_zmscB>%+8XileiJE63L1 z3T6wk2IM=#TVXf#;2R>n^8anOrkuaRUbm5geYB?QiO=`zivGIc&4H zV1-M+$Lu!qUUOey-GT$Tq%v~J0$g~Z1T}uf2x1@WO3~Z;GJ!b%#y|kRQajp?VU`S) z{!yrsz5mY|QyN6$-B>ZZJUlX;6M5}2coIMK7#dmV8a?fwPsP*%w4((k)wUc!A|{wL z!WDptfCIpLI7lBq%AFPRO9T^DB0j0^i{Jm|xG!t{IE<#RA|>&Ri^f&etEnVx=e5Rq z3%f*-hS*BMhH^|QSO2-luJuPLD+KZY8BgdBujP6hFX^usg%Y<}p+>MBO~kaHE9GpP z&KedBW8Tj>GDZ@)43l}=-ZKi-!#+3gcg+AZ*V*wU2}E}{jMnC$y%r5-b7!Y_hj)#9 zWC60?cY5X7BcTPm-JqV|@}2J%83rESfQU^d%L@5R*m6|_Wd!wOX|_XjDJmdr(A6PV z#2AkKp_tVZ+RsJVvGR}EcXd2E0z3j!`sP``1(`NdOx?}0Ewg!uHsVC%D-6nfwsO>< z5>6;gKI5T}ZrG7xoi!daa>wwWEz`-O)tiaXhirzmjHS$dd$~%GL#)KE1m73jWFAFz zObxRasNH4=lU`p&_A#*d5Q4s%VS1&&cz!ZyD3!52&U?4X&iVnG-rLhiyZD`O*_!2e zNy3clb|N^UN;4v=xmbW}BSZWL&v~o;hVCItkQIvX8$WyVdd;uZdv6DtNrpqltDUTc zs}Rx(({PvR`YdVBjY;{@8!J={m=4xX1GuVGvGI1-+2UGR{xPG|jzQeSJP)Pj4??1J zQfraV^O6OmnjPN@W4PUceWzxPBTAjfw8t$9mM6&RtsQp|gz|pMAX?G3gb1i{VigT< zjFC@1F$%s9Nbu;s!3T2tULP$sycUXTUerENU?>f8AHp%|o#<9bBE^Dc%TQV@rv|v} zcwT*7=2tXWfFc!IjE+ma$|B~w%U2P2looFF6_ui#`%#!#i|u|y1B0=)bmn~ClGZ!K zZJ3v}nhHm;OOvmZF5e@u9=QU~CwAxFVXD{x*&1c{C>20c!2-0yuD5!dMcs@dE5b5; zHxb_BapH1zHM#RKdjYiL`}+-x+<8iuY=2O=D`!B=D{c=i2uP@tRD5KpAG#nOh3SQ+ z8mVY2B`yhqQYp1dY*pzPXPAX4`|vu;o+G*T3}U8#nrt1Lz8!;78T1vow-Rz7SP}j! zsVVd*j3P0RUT~4aze8a|bN-A@{hb^qOQoUIFG1j5l;kZYwP-rtIPpI8d!3J}M&N?4 z#TujiwN-m+AWxT--)zVHTo2t6m*cIR<3`^B5#}!wBu=Og^wqOsCHGc>mta{%LbggX zw5uTd9My1_XgKGz$O-MUlJ3fn4rL)D$V6ed%WM86M^}J+>lToL=q1Lo9bD*m85Zgc);(%!5B#@ouo&fTPme2Qh3$Hz1q;s7I z-%u{T;qz>#iLuD?_hvVcsh%fvj*C}wIJBQjk`vV|<|}~c^3XFSYj(sa%vc8mU+o&l18tTg2@FxF##=~eN>Jch zYf`?ISq`9Z|BO8eyOHLHia1RY4+W^Pr4&gcG>-N}Jt{t8jKH;!_sOd;7?233P82R} zA_mbTUWn14+YD2QAQNXr4{H@sr>ddmiC1#7%9-*IX~W|kfL6xNs@RjJy^Niu9Wor+cxf!_}Fr3$`ZSL6RIJ^l@5 zh^2#KxVFOK1}O*k1F~(-y+sFL58xiJbYk5z)9tsu`zbHu6|FdOTKBD3DmcpUoH3@mdzLa}4;NjS8 z0eck`t`C;R2$QlHBJ#9zE>APHrgrq(A)p64YkNokOlSQ^WLWzU(RgpjDY_+xYB_V5 zb+pa`DqMN|uvyLNuK$CKE0u>tMf`$MpaAUtM79*ZFE2Ud{_`R%KD7Vw{^Ao`&Rbi} zHMVlt5AxN|?s_Xehn1{N;S3@{(JC37Vs1gu z=|1}8*7Ld#ac$|@^VcJ*=QY&s1YxKqKLI!#K2sptH!l`Yrv#3O@ETf@5Dw@bo z;W_fg!IJ5u5kaiCiz5_Qcx>m3+Ww<3$3ni}BKp7I#o7p_0zjie-s(Ycwe`qME7df0 zfXjspe8*$=1;d4yw{|JB5z80;3ir&&x|&OIo#g+=-djdx*?n!lbcr+w(v5TrNOz~S zG#4QyDJUS_A>G}b(%qecpmcYGf`ae5?*IM1qdGw^@z06MsSwfENHy#t;U>b% zfMMWd_jU*eZ-B02QuwFwhsoF&iUyn)YT8d1okm%9Co=Rccn>OP^x}GLbJUT(`+<5* zriPpe4M|Hz_zO#KIxCh8F2<~#qH%yD_dAqW zi~r#>3vP~{!)=K)25!KHX@C{Z(&rr(Zh^F%^qb|kt6zUu_327TkPu#iygd~__3}-8BN|BQ0bHz;YC%SZ4siDG^6~k;uT7a#o z^Jy(LH%<_&rLe9osoXE}B)TUN6z5(3m~4l7%FKDg8JhddOt~uNEoy#V)mc-`UU_v& z7sULESbfg~-#T^v0ESPYK$Q1(L^OO2scVZ8#A}YEJVe;gQ*!!iCIw{Mj&-|BvZ}7HJr2n#1pAs2@7NL`}Cj*sQ|Z zoqTiHWFas{CnD_ZXS^qnptK?t@2r?3Fdi#l#zq8{r%ne?18ns5e>&ZT?p(1Mg87{_ z-^ky$L939&X2d~-cOamMWy-w4#}SM~Fc@lMVsqG(yAYOtbC4_ny+2d^J}9fIWMNNE zhsWTfpd*LPfcmy5^ECC$;3LeUg?Krk>(bS&P^;SgkGDT?CfP9fCVJ8}HOS%88*>}g zx-9mF6Kt;Z1}!MDx{nS}rZG;ZTPQ8J{6`5bvhA$wML2o%efceUe@H~opsH@iHFuOk z^RVOy=BGJq7OD;0)d00JJU#&Nctc%kK0wKep_B?@{8f;!nB?0;pZNf6V9|mXvRlZn zbBb~_mq4E2h=*K>vi%5{++tWe7c(CK1cl9cgd2lAVR?77uJBJOi+`@Py^7hI5I+;8 zlqEUAs)ErLGQ%dTdut7P<@71@@>ct%!R_xx>iWiwM9!xMXU02|%br=c&zIAKBXK@q z;JPb-mBvuNR9B#OO?L~jN@hnhQG`>n2H65XTizrpKVjDC@;k5B@q zKR?g`o#8^ME^TlbutIPl55N(D`Hz74G%GahJwnRXQS)twZtc8@UpuXOV)1gd08>M3 z$UQN`ZMmgTna8bx+afJmpZ!AqB5ke*{sayx0K4 z4^5B)Qrc85=k$Ou?soM8*88QVns5TP{e9irwcbZB=#^4A3!KM(OS3X6U~rPwBK&*_ zbygub=JMR=ExhS{e0b8tKGadp`llqzzXMChE()eD3b$7qABW4c6Eg9h}sLEEE6 zikShDF)x_Vho?|q2T+JbQ1_ueef8n~`uRdO`SXvI`(LF0eDQ$(avf3xq)Np;t3u_I zbiN#apbwVs8MX!7)3+>mn$Glvy1ztsY7!C>3V*_8p%6MnBk5qnYfHhD-NPxUoGT^39~_mr$=@Q zxAn}`{;V3EbYgt_pR+9gDA?|B64{D<6j+o$s#=SJ$7_<BC3!I>XT zF5-5y;Fv!cMHJ^xr;hu8#iZ?pMWa=I_XZC!1dRvKN*bCo`5aBPI4J)<`cIXNhvGxC zeaaAiE2VL!tSP&zQK(+s2+w?LHN6dEEC7(8g49n3goyC;sDd(8ArnlYxc*#QV30A%Tn^{nV@@usc^YAm|ea)ZF zs{g0~5h#F-hTUyn^tW&Gb&UtqHi37W?Y4j2FhZ~ZDGvL{jqz_UKt~3(Sg}0s`O4^D zHR}J*KUi1DGhBd2l%m22B!5NR{D-}+_@%ewV2jy_-*b5=R#p251Aj~)U zhvfLb<#p_LsO7b{Z#gEwPh+|Ra^d;rpi=5*dSzyvY9MSnWdC=wQUcZZp!4?wGU0!C zja8;lZTLpt$`#MuYJKD6giJ4!6C!VExeH`HJyhXO0@9{wf18)EsPziQ+d%HqDyI;g z0h($tU}Qej%oa7w41v){x1WwJFfd@!BPxLWbv$thKOvkYCi6m^!h4D3AbylLSLH{h zttTf5;03ZoEtvlOA%N2ZLBe#@<|fgKHbeuX>9@Ozu8S)Tp^bj80jXKF(%;q$*f%1( zP>y_rY`<8@{5K!N3#g8H$5DJ_fBS`9Uljp9*RG@0Fzs(s!MGlZYqfjZcmD78^7>7rItu=6 zJbXW;fY1Eu`L><+uft&l#S(u#3EBMf9S>w@%E!TH&hNAye){WvEKLk{%V{?mlE0b? zjP2;MIcs=V4XTv>HXQ6?Xf{@NNZNC$btYXg!zEasSVB52_YR`+t{|{NJzH_y?+DDz^zJ3un3c78EjV8qPxS z{y!@aO(jd%?qcg1s2BeNRAqugJ;lHM4HUjHv}eFnV!CV8;xgP57=9+PckHzVU4oqX zG3O~L4yfrbEXEeBK+foY!h?n7wbQ9y6K-($fAC=GA)#t~z9g}Ko6y-TaPAbwXGLiL zpkNRGMZtzsD8EyUZ-u}lXWD)|`-1Mc#&mmlhT1@8X|l{H{d z1i)j>$N)UHu`!9GX4~~L#zX7xgT@mmntU$-XC=IYtx9JqdRWnD_&@rgLyO30ll~1&}|V?l;tfzYS+A-cnY{wS~Agq^?CyA``UWm8@O43T-ou))kWI@D%UqR-bZHS`W>G9b{(6f zrwbqr8C-qN(^z7X+yyK1U~@v_xbrO=@+L}jZF?H+_i3Ad3mm?FR4#FOw2hDcL)yM34x`rfx_DY!`l zwNN}wcvW}tLO>u&;DF&30svX2>DF0P==z_&tv{Y>ihad34 z%uT|;7{is-=dqhHxHCr60P6Z$kX*(NwO={*14qq}(}neHVU5?7bLJ(9DK3N>nmz(X zG`wJBQkN-kseS^<-Ujl$9Zo37HL_bE29(pJZ z`fff0V?Q0Sa-a0lmIlR4fa_Oq;MAJZ0Zl_*{H&MKiR|58j)pRSAh*KLtcR;2%Np4? zYUn{?>fW1?&n*O-c&1zn2!m~vx_1Vkiarn?#9^s89dC*4>ecLq$6!B$a`lhY^uK{e zq50;kjocB4F*7nU)NsB>0jtC>^4~v@B}hd;*$UbBCepV{F*#1rwI#4LcXo+3%|AB6 z^B{B*Zi72iiZmBYaRGZeOI{b7^FAl_v&v@kF?Apv&ao)_a;FOyh`l6ae z_m}GgT(PpCK5}B4-TN|abqVCc(sMV!I9&h_U+b;s=!M`j755(8WRa^@_w$V>fUhU{ zsWhXj{*~6Eed{sQ5~&_>JVmq%tXS?#%sAc=A013n&neEBH{bM9qZ15_^$o$f-tOv> z`L5odyyY-RSy22!FSIYWt!XLuM-N{Y1!{Tg2m;dLspsyC1-Cc~e!}j>^)+DFTL$%& zRODt1LGNeEk182(lY?p^oY8*TR&e@y++Sw>_H6h7gl-c#nP9$)A?n$lC{2NMD_32dmPd0 zEx9m>^DQF?TCS<%7fjlf5f=a~ZB@bPcri@RRZW6&dHcv7{Tkp7EOfD%_(S&2La3u;8z8<~Gc3Gm zy-bnUDC{*-$fP-F)$1}>Ly;)$^yzwQp~o+jbN2E%W9&=|MNXPFo$AMk>R~?@j_^`~ zIj@3^UrUIFrQs)+c1!`otxbpW?Yeo=i?wmHu*5h7<0|ue8+{)tcfRe%)?dFLR)23| zXVYW@e_HyU*U3_|`OS;frUQ5NVyR_S9@#v?##TCuAkNviQ{Z%{nW zdd;87jL^)``@CPA&}>42j`vx~s7UEA;H^>|e^&Y%)OAXbijBPhaNv!eI>*1g)vJ`J zU&bdJ0lFprarJ&t4tbiespwO6Wb|DC_nf?`!?x`nqSTS)qQ?cP=oDtgLC^GB$;7_$ z2s;7HU@qUL*jmY(Q9iJ7obFPh9_{QQK$HukMIL_Cb6Q$3g;rxw<9PwPXr(@x*%cJ~ z^a}PM(=Kz-vJq-*@Ol?!j$=EIW%~!mY*MY{7MHb( z;CUbOtRQS|mDjON0dvgubZ=7vrOkS_zlub<=?JCX$DLg7i?}RFI+gKo!>io#Z}M;@ z)R0iE5e=lpaqZCIIh0=1@OnPuqM^2n_<5E z359P+yU<&{7`~t5RWK&eYF!2AGC+1mZ!*uj;Q*HqyTVZklmfsWq@CJt^9|}L^+mkZ z$hv!(ydT+(OV?|^zCXGKAi z!Y5_q9%pPdK;*|dMdk-%>1fie8K|mOZfD89wPr5*1v=VH4Cos z60))-nCpvVTuMk7&qGq9DJRr34H(XzQ5NFvII+@SG`GA-gIU`z%F0y2J`L|-kZ@CrcIW5*2eKEWDNn<9^x*(#?Gu1AqSkA#r=vX zS(qnJCf()b7oBL5TO{+sxbdNMA}&-9q+!V-N~i)-_+5IAslwxr3P-)TF{vgUy9`|h zI_+ebdoE7kbsIWf9PKxywtXcBakXp$j>COKXU}@kAE|l}zwKRHMp4+YPs#Z;4KgN2 zr<0yLTykbvw0F2@$kvMM$)`-P@wtMl*wYM3zXNKmz4>(vZu2q35Ps8buL7pTfFX7< z#=?i1P?=5?9BeTLQWUF8JBRoB=tvDv_Z7ARgJK19x>*#${A*S;3E;zuB|*Qr!xHCH zh{(f&;R_FZ%4s4qu~HZ4jcdmf{#^nIlY;c+u99aEI}k&J?lJt~l8;dJq~IDB zPN18U3`->|PV*C#VB0odC#>5elndZjz&~chNc)f{Bnf*LR?<&7f_$KcB8Z0q@nfR8 zn+I%pM6@^7wz0k^IIh^2z@kdJ->^eGYdl}Bt5%cXFwhkr&fn$Sw@LIfik(cdWJqNd z&%zizNDoT3UErc?8M4f27({fO6BzBD)e08AzO?L}LCn~5fHFP16!ApE-7anWdjVc)<=cY zBwQR;aVf|RiijB(r=W=(vySiTrJf@dM1OE~(7I=XPfch)qX>`}^C#TveH83l^HV&J zHFfI}ubx(63h%X~yaq|4V-1di@C4L+V90Z%=G%+b+Syk(_V(Cqk$)&XtXPavq3_wv zl<+}u-bcpUHLcj~KP&V8^w3>u?L$}X9*U2DPNFKWXLjf|-cL@23Svaz*BS05RFW); zSv5;!;fxv5H0ie0ue74ocrHg{^7SP$Np~crs$sQ{VP>XOKn-Z$C&VGKc9iJg7E5AC z;f^n)O}vT(6`tYvOQhcDuZGlq&&e%RBBW#A5*%e5!lh|zs(GwSG*#;hdxI1PP4b{% zWYKrVeO9ylJ{5S3ps~g!3tP3~R)6fU#m;t#BDDyXG&4O7_Ap8ko^O9H`gv{Y!d+d) zR|HN8JB!Qok@!u@QkG~%SK!oKm+CIjq#E_a8NFue zS4YN+4rXu%tz4jruql*NlH>y~Kf`e~d|vvamAZFCo+V{vOqXA3C7D4OMs_o#?u==RHLI>OU^`npeH)j)XJL<07DR9rD zy`D`CaI)(yw+u(FnYlhn{$o`RI=`>$XG^#!Lb=5!smAxr3iR`Wt%&By0AfBxA5v|! zVIk$#Kbbfmk&neT80#Cuk zMdIC1=~lZIYpHje23^*s~^*mFQ^DY>MlWnPz`f%$^b?9?LA6`^X<%TZyL z#n&`MmmGg_(nlrk7MoZ*U$dpL@%dX!R!}NpZBnefU3x}9Y}@Erpxa=Np}`h~jUZf_ zPC8~{-y9(?`DFX)trPKqykfrhd)vmvt4L!m|AokuRuUXU*bG9#-d5nl%w2V<-g`?} zf!Fnr*{=*@jpv=WBC*fv>G9p_j0;N#rp!cG6`Wq6X8Rd z{_-DHB4ZeYyHGMNAA&C-jNm@B6H_LPU_a6&pFHi%w7lff7-2Ihf2$J`d5NGkx}z;W z3{2gu<@G~2@snr5+b!2Sk?1?N(i0}(7UgC4ya~P)I_?jk>B@Kg4mIEU%({8+l{OFE z2n%S$JimX5n&mGzXPUd^c2qN1h)5!F+yBIfBr^MME3m520yg0?VEgu}cAWQ{q*1eh zhpmbD&J-i=9vabb7;ADn;i?M%c06=j+^3rQRa(=wdxHw~hLluJzg!~Cj7Vn(9yG&u z#cT92&!LzbYg+b~rVfrBJmi*#meblBx;rm+@xwL&+W*QHL5X%YLDCQ#hBC!+(H-d; zaVW%4snpWr4?*kitadKnZ???jog8C?NYwgaG^nVM3K|;}h(bOytwKY-6l14RAcOvg zuiEC%Xug)Kp)SUW%jHTvwIM_|dst0T(Efg!zU#4a*>?J~ZM1E5mn-Wnh(r!oI(F0F zH&?y~H}X_m!aN%~9UAPquhU~>fZSnI*-U4F`tlavlhv*U&)X2hd`ErTy@E)aV1i+@Yw5|jhd}a(LH-!p@FJblNG|u) z>od>==BZp^N+)pTSA<-93|q(~JF_cLcyuE^SpqUKGq;!*w8ob{U{2epebW5^j$xbs z`HT8VMT_4HgTm5d=^7FgI{1&U0zz@JStzxH_2 z+T#nVfWSLOl2dLTlECA`{RzHcMO3;mY=bpQ;m#4ow}DdrjG1R`d-6EKUBIyg{@lp( zydT?hR_S?vehnxTF~jH%MmM(ItWJz*`}Um$9%ITE8+%mDJX2|bZzHYI?=+PM7n@5Z zr#B3ljP}rg>W72(C9#$^R{AxrwM_mi2rYZp($iOeN;CR&5gKrHW25j-lH$`G80`40 z0;ANqa(4JwyOAolL&xpTZDsaHhzk7}PI29B!H4CT5!J%(^67!*QA_nqlFA)%kW)!}MO;-jGxjW1s(mBdeCZhLzyP8q0m4sgIQWLz?z zxD8AFV#rXNHq9T-2;n5=@F%Om;D|5LP>-UP7E8BLr9xG<5>)Gf_$kXuNa)y42~Cw?@)#XS#W*U^ZYJ6I)FCbAZFt=eU*R{s7> zt`Y(Vq7Tu6({t+5c%KS*8=UoqaM3E>TGj}knoh$$y#Z}D8WUe{3sYM^>xy`ACy5Wg zCh~X15sl8Z(+%);H{F&u!&D%b4@c2+RfJ&g7J=SBK~)UcwZbq#f*FVeI@#p9rO5k( zKeeLsSB2w4p7?KQXL5!TvpSaLvLvp#u zPvwmfu#HU+!r@JIVJt!oz*_)KECx>cE9H|6-&vRMo3Pb_%(g88a z=CM$OJGw44THDC}TLxFPBZBnIi`b;Ld~b#S@eePerfUH2kIky<7lU-wams}_Iwez; zq8~gW^7*In@&9*C^uP6x|K_Wwh~f0;-u2Gu&`v49k=(V<>)Bj$B5wX!&6r|>bNE5vF=D~AqgLCvJK1f@DYs`v?%oRYu9t4a0 zqFi(b{|4&U=ry$neU|41v{N4xKL+9k84*8X$^XPPQ?Xq@_8^luZ5!Qtf4iLoZC;cq zEO81IIQ;Y|W(%la>d{%r4PSmP(Kv0|#CUoqe~QxD4^nq`V_cJ1o-_G!M5w7)%p`1{ z>yJQ_#PrN@QpLeGARNRD62vBko>A&)5{%15Q4QZooVxDRV4I$7u(g|ZbOR^ZjX=Nr zFWoIoP+AD0rLv`K95{HHfMH9JHU^#Fya_Phfv$0E-4DR3Y^kerr~1t*MfRzHG>0$H zaHLud8&3Q+Gok4fZ2W;}SUozZ@ChQgG-xkLMvpv!6%Z|fMCNf=XVO1&f9fO5edh1` z)!&a1p|Vf=j64$$Iv-X|W`nSoMTCc4GA)+bBEsgkhZ6sviUm0^#}3@xHMgPxJ+B-= z7`owoU1rn{Boo&Vv{AITL~7)~&`sJWea&)(;%lWU-z}N{;a*#^<>!IfHbJGivNk!S)&KE;HzB?Oe6Z}j+03AF##ob z`(lKtN^&%GIUt%mjmZZ*Er?KyO$VCfhLI>6Ub}`+V z-;*|DfL$oOdTa||jtd&`EO&ax#2Cix&d#j-Lqnm$FXL#U~p zxAWm~OZf!N&hFix^8-mGI6I6r?&N5q*>x+-F66}{%u$0eDT9J<6`tBLlX5**w5B}t zLO=NnNuz74(E`YzAh&bu>xywXwrB`ce`I$MH^g`ri7=ECH>-wQGsTscOq3+`o zOI(;n+)0=jFG-?qXj=&Wv8`k6e#=F)4Q3cUTW3c5x))ZvazWZS>1zn&x-S{*lYcfQOKHN$O713lKZ>7smJKs&j*NW zjGYgG3*W$fJ+N`XG~ByrhMmF=M-!%pIv?^n*$(t5_T`Yx_V5CMx;AYVFsdZnRivlJ zG~TU;a7c^5-3XvW4gn+oFf=QlY{EGD>wQN3IVPJgG%JEnxMc!_MY5H2bo&Fm;DUlZ zwguvYNKkFGCT(x~&njRqL@^FP$#{Z<%AHdJzwML)No?MVRj`svdDl~j;$Pcu%C-=z zLZFG|tWbyWpeQF@sF>G6$=`V`)eYuwOa91y_E?&rSHwEKR<|luy23u5NM?@vk`&ZS|WLzLL`?QaxL-%w` z8ykAu=Y_(!g|MGn6zb;x1}sosw%wP=;k94%Y%2Iaxg?g6`ts?Aw>$18BjT#5)A_OM zK%|BMBjgba?r~k z)g2c_(8`iv$sZqUTheEZ)h9aIKaIf#y7?j9kB!h=>)&-srJh>#2d9$6zCKtkrSpG;qCn>bVM=4D9HVSgv+VDdt z(TDK}mo9EnI4*hlg%K#dAW_XeJQnO9j99b4Mq}}* z=xb>_vl<1=>y8*4#2(3ha}N>|UkW`wXCmTc=i!(#Pfio}-mF?ImZ(ILj`ES`vUJa$ zWHI*Jruar6%UQr*@_!_$g6(XZ!$dajRu(T^J1y2?AKt|Llszpvou(q)=9Wp3&sjhV}zMnpV@+sMMBEF&#CW$7a4VzDs}^@A2B z#jp`$l6(;jLNTeSotRT^R;u4cth1m*1ddwojHFub9eT~mEqv&hJB9C;`s7A!knLfcF=J1&db4rw0>pC_n2GUP#=Cu_0OXVCa?qCmo z4M$hjRCw@6a<7Fn$UPC3ls%Lk#V+{?Yzu^@aGrIaF+2OI>$yFz@lZ8Oa5iI| zq_$jM=UFhN`8OqeNNW^+nJoB-QF;KWo|Hwu(e@El|GQp2Hf3&=l&qFK$YNR>G32F7 zmGuQhuf7=ZwB-xKAiKTF&aiqJ?)dr_)1S5K(G#q&qf9V+7HJS!$*9J)hQ7=3ze`zy_~G7NXZ-OXR#0K;fC=iM`b6>Rs~HG> z%-t{Ug+3Nn_~5Cd?dy?u)u-epMTf9!QTQ*_fi&qWI*ney__%wIr8hBF-8^=4 zIy-FGg&J>c^{ViB0X+t{nxf~h%mA@CIx}yn%fLZ&W_4dP;GieTcY1E@<+(Eu+3C=Q zq+csW_&8~?UX6^sT!IjVhG~jj=_wF#s!lRGzzurGLx8CNZQtzDEy1MKDetJKUIICp z(vsE~&-Xsu{eDBMBN73XOC2fa68(2rZPaP}r&rrH1I{$OhHAVo3Y;@{uy|#1nI`gb zT+Uf=xg0N!H?1Dp--Z{jTPF8^o{s8DZcxnr`2ZT`!lgpey_ zZCF3XLBIvr;UUx3BV28}n@iS38DAqWt+`kMHBMc@?SnJ zJk&DDrRD(8@az!9&H5MU)EzxW_WlU_nBfGR&xa;h1Hd zR@7u8fY%vX$ru2OBAw{3`esF-0Wvfcx(>xoZ{t<9l}W(i>|w*Yd@nv4ed-e(lUk}C zm$b^0#m?i#ACcpi+rs)YBj#J>f!m$v69X4)uaVlX`DE`wC&Mmxy71NN4z$~)kEMQGZkZxdxvsa`DU=$+mAjS@0eRhw0 zW`Qm#TQRXm<%QI*))Cf5Y?IYe667a| zZ;8E)h={!?F&4&lUi?zSeRHO%U_(U8wX%{d_q3<{+Zy5!%3)McMwp^NkQ<&epkx&f z7XIrv&PIiGsj`BxBG|`aSEG>WBkkernw&stvF4_n7M{_XaYe5%LKqYo$8qu9OPn&H zW4Uu@*we6{qO4e19wC!0rQS1>>{SxpH=gQ`RCwou{*5VPQkZKX-};XKmsBi@Nw{99 zLi}{G520?r1|ltH=2c}kP95t-{O3T4R<8ZKGi#!azNQiV3cs*Ad1kG>F&V9}C(WzLqy9 zksk#4^1Y%6CJWImDbZoRHr@(EBBh>&=bRUB^7zs4KWr07XvK>`dpH-gcCCbz2uxCZ z4w9TeSOFz+ou(twNRRnkP0=>j9?^?jF;=(6KlHttr0F?#Bzw~Zduw9ZJR>H!4<#{$ zc193Amw%p}K!BJk8u553cVJLaJ!TgtT*|sW`#EkR>P4@Q2&IfDnO5t-zM~2aHz*=2qoV5#ILs&YCY?;vCp}90DTj} zaoTL3wN!ko*_kg>5-|Ln{`q*p$4vNr+#REn0wQ6U(~gPSaW-Nh<{-!ZX-A-nTTrHNfq7RCd9wLv>^1lh2lgEEoG z@$~bK^Tw9}5)~X~$MYXV>_$Ralu~zL>{!sF^DWS&Q=1JU} z#;WHQp-Y_{v!(O0=?){VC^R-81xAN*| zGW3m2ZOEKYCy2~It!8E4lkR*K9a+gXPW4cnpVErUdG&lRJ69*aXpjc}E(PVyFgkIr z)E7B6ftL%faFQc0n^P!ixwr9c3 z4lyLU^flEIKCdz3sYJ1hw{b+0%WEJPgXc|G&)Sw|yvihbt z&8lgYF|0@L^RR5daUNJqo@FvBcu0hxS1)RYrIbutZ1f|!M7reX5A+|xx2kI4B*1N& zgYSp_+T}F@N!xj{Xo*+xXfcLP>);cqiuMVCL-T}q8ndynm%>pI1AL*b@%jv$I@ws9 zW4#6Ma`k;ueb^#L0$U>rFa`FqBLX;nl@w(@P5PW)ejEqe(IRz@HeMoXJ*Ii)kn@p2G%k5P%S-iaI^`XEb6mn6Z2Xfi?I*75 z5?ok3%+E}3vyY2%#n6d);1nGK_&AhO48$1#Kv-xP z`c#TPI%c45SwTOox@8RwhGIuqWecNez2da-S;Scp{Gd7y&W7V`O^KU{#}cWieX9A;^7O|ZrSahGf11s%z@A9W#ZHg8kTV^~uYc9>wJ6E@%%?`Cb*8&$T8MTa>K2^fF3@ol?}qcXzdit8=Zs{JS81$ElJ& zZeKlbB1>JfX>TG~p|f++-J|z1%7M z9D1c)UkbrIj9Y8Drf*-;zVMCX(2O@qocbXpDa$g4ms|S00{brdY~k!xp77dXI40Jb zi)(FU)EYYfo2lFWL1V3>reAE|~?r?dH^bVt~*yVQzV1%>-!)5eaGLNU{ zR`ZURH!~91^b(l9$daRE%N1jCe0S3&D1q3kC~TIrylnqUhz8i%l!f1M(B$Js>m7fE z_y#3(zldAf=dcKvK3VhkrubA=EVjS|cZ}SEnCj$55lwyP6!Agh!?;eG!WF8E;9gli z8O0{~Q)Gci1~#H_^{MKC4xyaHP}}Uvg69gtX0|JW4tRoo*?Mwy2;KX#Ytgm%ERs5O z0)ukMe1!o;76cx5ye>CG_@f-oneHN1c@tq`Vq3n=2QFE0Iudm3k3#*2jI33p4XrGZgdX)L6k@1zvBJBN6s=|$1IHQpo zP(3GU?!y{NQl?L5l}_=u=u)idb_)ztKPOswV!#$t2{uSp_>)N8^B6Lj}s zMkAY^&~t_O=i$=YA_rM+x>9ob9^X?xoElIb3|cO*PPX4zq9^p$U|64{a(M`8u9DKc zcT^bdyL?$XpAeV{gH*XNUl}1I#PID7BbC2qUU|Yr zN)AzX;>RRDRl)x6(S;OAZ0xn$8iK$ER=g?x3^xOwrt>19O7hLqA5-d%a;~ANq4(j% zrGzQ7?-|l05Ktx<1YD)@A{i+ddFl@kqWL5q`J6#}#stl;%Q0*XqbFGnKGmZO`3-Io z6g;w$;y4lCY8c*xa#Fpgd6{7))I-)~KWTiB8zLqRJp15oMi$n%;rJN{iD5Qeowp+$Rsj7X}aW#bsi@B7SJuu1r^Y)pb%b1Q2ES%r~+ zO7e@!_joIPUUi}de}6Rg*RlehY8>L(%Fj%@I^_;~M-tpAOQbjkWJnbl1)q@NQp=SN z0@>qRd3v#B`AYRXSH>pf|=hE)oRODNJJW=ShH$nxn8yZ^LeFw8gljp%7VifWc!yFM4jzS^=lN4IOQX@t53 zj-Es#f&0fnqE~_LYD8=tUNlUw0^Tn0DB`=mi9#!eWXOR9!CgAs&aqJsu8hge;xq9X zMy+hDaZ~@+#qWvwV893&9_=v{nms2DAS%AdODIBAuKv8YMDu2 zrvgh{8M>s5MD##=G(K~bE-`}JBmNjB z_1t@fK14~k3`}T5CjjD=B!`}uXEK1q)ErT?RF1AfhQ*-MMB;!ayf~Y8nv~PiJ&MI; z+O2uZaBx2-zi>y(M(<^uiARp-_23!brd@LvOy>Pg`pt?^2jWs&mWfg5Qm$7a9t3BN zRzZUQ9&uK?!#f6d1d(IFtGNoV-F{@$!0lV4h*!Akcwuo0c*c-7U(GCvYWaCGt0?rFP!Rp2vJv_T23}%;kb%w(#g+zgDEnft2oBjVo>*XdCAj9P{dD=oabRiqOGbWk znIB;)!3uY;lNGVDqo|E_=}lj(FNI7si`JT^_=u*`qD*i>GF=H7J#a?`LQCVm^6r$X zVx@k>xIIrqKvMRJhN~m0>(}SqB@!_rpVKp(ki(ERRTEJy$k(Mp8*=x#l}sis@xCQv zdTMvz`&l+f5H4a++zdq}73WuL>9-VqGBplonsJQ-b>k*A$Z%gH!DgRPIDHT$8OgycxAAzD&_8j*Fy zu5f1>McO}bhEhF$_oGGVS@%-ZO-?U*YGOd^yLRiV+j!Y1Z&?o)T;Yh1))LOX;^v{9 z9AUj;K}^)1o;os^>SRVEn`_G*kMS2U1Agw7zOOQr? zrIemh9LY~Hljlj|=qprswdy1A_4`ZpJXQ)dzxufRnu!UytVQAw7z=PmPl}n6o}FcV z@+y|71=l5o0j`3J6vlIeVtg6ZS6FkAh$1LEz<=e@yM!G_x5Ojl<9E9`+hbOIQ?BMq z-FbuK`H=#N%nt3VbNt`no4?VR!H0H27DlGvQ_$)q)dojHh8Ovi~MJ~8x8-Mto@h&t<~{7Y5-px4hL zSSt${^GNk`k1yY!5~zs?_g=t0E=ViQWD_W~Z&XMU<9IRtd9LVq3whaSw2wJ|&!6!V zdSPLQp@NU6Q6bXfRzlyd$Zv^fO)ez%)*~bR?D_agv9T;8;xz|dS z*Iqj>+h%&j+_99OPk%uBti)Lufq*3LgOnLm`FM1P5it{144``6plHu;~AMp#1c2T~$D_v%05a>|XX{=OmNJdZGyQzFP;%?OLg>e+c4KYj48<>e|GMu}de z#f01LC|PATh!uKb)#rI9VY+)GIva@C}o$B8dotLk^I2==f! z*s4F@e6)LsrW8-9R2x9W)5k#FnJ9+ya^$Es5fk`(4hv9Qvqjx$ISIWsNN>9;5A_TTsWi@8KBgS4;5>aWVfA1A6P z{1n@PHGL_6>n3H{8tbAk8K!8+`)j3D>T}*Cd!0~I)YNf3mood5=!vTmgo6I(qx z3*XXmMp?Jw0>Zh=m08(c)sGIu{xtrUFBv^~U)|iAm>(u{6!{aO^#z#<474!ozSb?u z=dZu5UzE+QZ0iz{{*n^>W-3aZ!JGM~@t#85tYtGJM<2vWVnn|=w-llgS(D+>{mUof z1eF#qETLFbP3VmtPw>aM^^lK&C@$1*F4YPkRKn~BwF`IbG>gBsfS-ZU{*J^^7`XxM zidm6FnOaQN?Z>D@Xlwbcao(j{Y{@GHUr+rRdeY_+qr2=m`9=&>^vAv!1EKuim|<7a z{Tv@|ck9Y55Z&kBdwzNOWP3Sr&bsxk!elv9el83@U$K$tc}A&@hctsr3B(~8?IS)R zH^=9iYYpxe`GU|l3a=98>C+nAD%h0_b?B@p7(?BxbeLkdX~(gO7zUHW__5A%~A}jj`>NX4JTbRg?6VAC;0Xr@T)7_!U7lkuc3k|1v{8}j`x_TDF$OF(p}PxbhjWO-5}j1-3@{Qf+8i|or08fBOyq0pLNbY?>OhYd++!DeaHQW0Xo)P zb3XHlUw%JCRxHUMm~#loHU^7hhJu+;8OO?0V} z9vbB_J5fGOBPZQGuuZ!f5OLb&7xTDdvWG-GR?iR6)Ln{FnIja0<49=;YsT~TBX|FV>GD>b^@yu4VI zY%@%mJ6Y{hZI4t2H`?C)&Cdsr-eCA$V21oybk-``y%%-ucy4}~D=qTIl&d;?1F(P5GDG1!JY)LPZYeQBb$-DD2zf)91 z`y>ce_!y*h=+;`5`XgJZjDPA|VavKRn1ssF>ix^XzN5wrw>K4qs@dW zy(F-kVmhzTDPy#lh4p?VK!zqbe8=3GA0k07Cgfx?Yd%0}UGAD9@BUhqsv+zBq*jO@ z%I`NkXM^S^XpPl{ty?-PJ@t6@9rltj~-iWYJ``6k$y=1YB$6NHn7p! z%_Rc6Y3;|G4D7U1YFA5Sm&)KZ9Dit(T~R z_!d7r_Io3-%u@4hz!oQLEHaVW{B)y+>pQ_$Ub+)$jS=eN!QQ;Rz|1#eba70t?wbri zfrf1=g`53N`cgzV(n)+UA>{MoMx3)hJCP7NQS5wx=8ioJz>g?R24$sC|}Fs^@?v!JD2a3Ph5sl1v=m;aEe?{ z#J_2B(N%x6m10;%P4S+c^pnWV%N&i95Ab2pkKQ}lW)nQILRQ`OP+Blp@k0-~{IFn4 zf8(nIrq^_>VtK*KLS^P=1HaNqWr`OmpPGs9HFIg?nP+uln&EbhC`V(cWK4P0HJS-{(rj}C~88-StlpFbuo+o+ z%6Yu;f6}m*6F%z2aF%=oT)U50K6sGUqFe#zt@{>2(bjy`mAZNJ3Kk@o(w0>@`T~F^ zBP~|{Nq%bRXM7cxSee?W)N+zrtaxqB*qMmId3W}h+Fm;+ibd}!&-(NqZkvs4pnJw< zjH@f*Wg4q3{Fq!6e^C4feX$XCH@hUyaf;xT-B<-naLnpc% z-_+EPdCTF}{2UZ=Z?i;)+Y&AfHGwk*N#aqaSFclq^*0E^610v!8hex=E_`gUlsH@* z;X)vzMZznn$R8#`HP`xK_4R!d9)zQC3g5?Dsm@} zsTr};Jf2=~Gw4qTnPXb3ml!3DQDdc}%|$mCyI^GGU3#qC{K1~~eQFxNMd^dV)6`fU zy}|VX;-2GT&31@7(kqMJFalaL55hg>VXkt*cR$>)rp^LSE`McvbXp`LKQ|2(h9ehQ86cLc$UOL z?jzoe@mUnB8b2f5&iyv0w%;=&Dr6WqjNm3!Obs+}({?4sQ{Qn#(y@a~RATb2u-*th?CJJ2IOqEn2#1 z@KN{9hN?O$2MI?QF|L^TPqfd8luUz0dsP`cK~4O0!BeJ^8Du%?JGGARr>=RYRean8 z2b8=GIJGMt7Xa33lzc=;z*dxT{RdsgCf5Q z{^Ak3n`D{z(1J$EJ4S$&t-%`v|p<|qHF^h^tOGGW^j z?)8Zxdicl2;;uv;>A^X4Sg8=#AJo`J2ww%so_nPg_6!BC1|5F(l=Sq{!5OP-&kOY3 zh40Aev0!%72=Os;4 zFZGbn+x5rZ>(e|N3E%0;T%TtbW8ajz%2GK#dsuFkn2sLfI5TjruR>&kjIgW4o`$N6 zb1E^B8=w6UPMQWwhlL#BlU$F8;t~Mz44Hr=+&L*F|o|55?7 zpNep*5g9ZFA!2gfcl4ghVoIT_C~_?9l>Rwh_PG_CE3IOnkZmZFx%=`*R>0YL`ofhM zdvq{saS6e>)ur|?2)FXiPfvkqW1VVlQL2Htd>3j6~wMgiv_H1LUB0U0? zQAX6?x_gFHwJ9g2gGd8X9@7Ra6-7Tn!zMn8{~&}x(|}sIW?*JDUJYPJDdjeoMK4y1 zJ_MZNtzKI;xCt&*k3*(1D)s5uns%s|1egd>=iFFdm3*b*;up3Cxp$M`X739ECK8un z&dX#2J-V8M$<-nc*^o^G;_i=b<vG?X3pX7%BW%?bY5g;3^$HIZxe!Y2be7S0m2?PxHYy&9F(NSc zUR4mr>kCEil#K6f+BX~Ebt6QMkDg^9b*gfSgg~}Nj~tAD4pSQ93nBJu zGc=uHp=ZpM$nM*a6E$ZNbjfUNlTqmKPs3Tv8rD^U;F&SNUb!0DL|$rbU?G)gT^6C0 ziMN1S$erj(>P_vzsfzy8%n4x__SRqO0*>XDS1C!{i6x!2EzejS6d~n;a+zMlw-Ah& z$nlUZOBci(F_cjht^h~gxLodC#QjYSX1;2p_>2CcuSGVdOQ_^bw1Y#({0Ik@_26)9`~3m) ze22?kH!Ag9Okp4_w}s%A2n8cH{rS()wPEXF#wVJl$T`&!hB0f9=ciXWnx%W2z*vVuz_|tNF{3s|>N5p#Da8xg<$=^R33^=K+Lsf3M1dX2Zt* z1yEo#%aQbcSKm`$^=%qNq1h7eBoM2r` z*ONgl(e}JWnC;`QplR|5Uj#i}nMSjEUuxgf z>KQ1CW_9hDhrOVVM3T^ddiM>qTk@owpU*Y<@qYAL`&5By2~?$HnhJ%~H$n!SOz5gH zCYz1S7WQbQOJA2; z8c_Qqv%h_rRD~4sfo6sM-7S3a6^gRp5CR_LgO?{>5bV(!{`WKmo9|=Z5B%<#ioqw2 z6&p6`_w;;=89nqmZ>5HNdd27(RVW`d7k%xgaT(P?TDzi;Vs8bghk^z~#+A5d~-McAUEa6_s?1}01vMV+7DFX=~b_=BP5hv~rgATRHO9ENpxBU}Z5zRtz z>DTQCH;}FoTf_|c-UuN-Y*N41nq3wBGGWCqS{eMfgy!hfxUGW4CO?&RkCt!L;h2BnZ17R{jpE%ld_CKMO)Ma`%=QA^G$xW#B_rAWCqD$N9ZZL~@+)f{t54)d z?&o5ng+#UD23#k%MdEn(pa&xLA_YHf{uinlq0a00_?G~q@q{vShCmO*LQ~LYO%O5) zB&!36{X|~xu`B+97XqC%SLtfx99`&!1X#0OrzN1b!{2%EF{D`8T^JdOe+BPs!I1q)MGkd;>Y4rRIsO>T>}4Sf<@9 zf-^`pFMUs^MH%)pq_I=Tw9I;z=Y__Ax1+gk-;68s;$FQ~W4%@cpV@YFwQxU7#VX$r zpx`O1wluRyzr!|pn8u6Ke+6BmSgdY(ig`f@Zid4Puuq1phKwAo9wB-~?ETJm87){f zA(42mAD1S-I_j`DH|BTbV&h+Nt0pjoQlwM1hwxY>q(<{6v{hzXR$!$hC6ZAsrivhH z8@Cu$+6Rj#3}nh?7i&%*!3GxM)(>%Swn$irXbxmZ?V5bW<|+2nvDvm&Lh8{l$%Gvt zPd&HyT8Kr6jHSz>LpFdfJ`>~FA^4$!9wCF@URH-3p z*_@C=7KrQc_YA@vQLgebLg}Q`=%;TshLp>&dyTWM>VcN=>_U*(ga#?#~Lhk z-x4lB1zLi*JYr6ix40_WS&_M9I0{_?rL1xVV{kGjD35wblp-^YGC(PAHZodH*qJL< zL_~Z{AGSp(NgW@=nrAN(I=P%YU@H0}oI_Xs;5L!h5*EQif5pMs2xwsjB} z39&0d;P|a+U@tqnvnwWBPiO3D6nPPxKaJ8@V{TBwMx*%$Bsrsk1D>>jo-AFJk|Rl` zxA?H>GM7E!d0O!LGT(wjRD|fT3!hREw}?uqV_8*o7n$P580E70d8Oqjd|?TPL#>t% zifUf{O;(UeF?dPrAI7*Mis~t56h{cTGa}PC%(7nP#O39yYmCL9+4#&I08pf5LejKidwyFl1}&rAPr;2K zcqltB6e)Qtq|pG1ZDPgSdaIL6HG?#%b~92@eI=YFD}#ko6_{ScO~}AVeTsO!Z7q7E z8-~&c?SMmwXvaFBE z_q^Lf6~LQkUQc0&F8xDPd)yJ0+q&NDmZ&%I4WD(I2f9Qb zoS?XM2G;8|Hr+0XXTuztwmVnP5;0l>80DNkYF>vlJEY2H7>$==bg?Jz{GjOiwQ=Q^ zFrcq%M7QIb-jRH|n!laV=lX0+M_c7H?U_8!b;|*YbYAVbO5l-jn@CrZ7Am%Fa(Tab z9gL?E^lw_{s;wiJ6MVlhkrDaX|fN*fp}WoUNNwt_`KCV=Bai&})}%ifM_EL#UQPbuRlsUtYZgqT~r6 zm5$mCNev0~OX;in2z{};Z!`ugItOD`dl3(Ip2&L*MqI&!8Zu_X>J)nlGYz62t(_HM z_%ZMfw$lfvRf~5gH%um;lsVnK7=)HWBBVV!N9c`|zw6?S7`+2ZCGoTxiDv%kR~4!J z1I@Z^=S2?I1-wJ}OxGSv7Os6P_~ou?3p3h^`*AT^dkv6=FSd+@#Nl+pGu?`neP4}i zzxS&rqq^uNafyQ zROVP9u!Nr2-#btYo1hM%=#I=e9UDz}TUDx!JsKGVFg@g^)0A-HL?`@B!8$Z1!XX+@ zb?$3C;b$tT4YW8)rX(@KJFPWhX8=k}KzIWPMm_KkHZl|U;M=$wb{QUDpLo|nf_WP! z)7qD10!l4U>TFKG=Dz;ice}v1_{_)+F)KtE^C+AtpkX;h!BM6hZt+k1A<0?mO9=D_kr%Sv4hL z4+Xl=f|+MogBEArEd&O$<(W&qgiF=nS_=1)F2@_6t`Q03q!|kRu8T!I zHiw-dKIZyJCLOMdZQ@A{9vqj7TzwAJSJv?E7jr*t&A+BYc6Ye`uPt- z=@|n)r12*~qA23CD(AMI)_FL}-zbn-%I(X>`B`TzjwS87s_STm=yku#xWz~m-|GZ* zNh!mtQk*U_H-(LI`as)+GT_raM?FTgFO750*flWz z^kil}kC1_z%_;wL)wiJYn&~JOjxxx>qS-9m=UsK#F=hMXG;q-2&LF+7uz;j9XmP%#gdgKY4iY#tiVRg zya<8l(>1)z1%;V3MN;_^r=e6rSisGRcQQ_Dkg1#+pqvIP)e^@|&$>`T;G8%joH#Wv5TmKwOqUDlz?}tbCxhV87rhofIol6GdX&qRN#|Oe8 zW5Z`k<~Ioe*;1;W*L~aWUSehK)k{m0Yo^gV}T)COUd zJ#B%NTOzvejbC39$VsEpvb056hbz34_9umJYLeEBk!VJl)L8wnjOMy_XCv$-r^5_d zPam(6M&NNAzA<+*uYmX1{Q-$?GvJKXS_d4U_`tjLn3ec%#L*&c?z-=9&ZLKV@YxL7 zw0^G+$49d@MHD>Ih}AnixVY(j(^;LXDwyZcM!L2d`m;`>zSD)tLx|v7jFFB(tI@nWXhCp$+Wimr;vC+x&a?77n zEs|es1C5++KR>d>+&GQARfGkT5iu#4?<*OrV!dKaD!rwMHCFzM&eZ;|bEf^9a1KV* za;pFI`Jnxzr`BH|-%xT)LuEzN^FwBHVJMkd)3b0i5;*kg7t`%rgUm8vD_eAVI!rOL zRi8CMRwx^sPf;YhQ@2&zk#<=Bm?=9{s`Fdku9nM-0M_7#ZGtl`V#x%nL!JJGr*P|o z)@we7v;&x#*$kG4Zbt;m^U?Z|L{U7f)L>6N_U}oV`8-0 zIvk)^!wkoHxDyD6o(@O1RpV;`ID8ga&*L&}8te;MF3X+j64@3;leFC5TbS@Lo!Zm= zmv#*|Jx6ID|G`p-d)7*Jy`=7$wdgzA`L;JXX@(q}@5l=?K&vz8+TDUL?CV?!d#)}$ ztO&UVN&ur1xCE3C*6Br-G7};ZdfT*nMh+swG-(N-nr1=QdF0bK66!# z#N7vxe?GwfXgA0R266hqUf2Y9nsYp42X(c`^5o`rU8E2P$4^rILsg+26X(Y`zjAIq zOb?MMmq7QilJb8F7PZe&{y?5H-`0&UNJ=g9h$}GD*ZgC=1CdQ)YY0g>q^bX?X80c^ zl%Nhuj#3X7B9f3^s{gr}KcqGO;}*rtfdU9ok@_E#0V?VGNlchzNzfns9{%5wL*id)&tt;!_ z6Jm<7zEe`4c%F{Ma>odc7+k}gC~i}wl-D%_YeQC2X_$xgY0t8 z^*Q7}Hu?V=qo71dFv$I0Pq+TikojNN`NyZhtr7h0Hjf|8q+MhBmlnWPN&Vkm8^qpG zVb5Y>>|^2#gwBTRR7CE6{n{NHP3JUs-Iz1@TZj`7I=a0)!J!-GvRwlJfWywkADNM* z7#l0`q9+^4D!eH#{_AM`&*$Q>LIUI%=YoMJ+$;|GGBIJCqlsouJ7$hU&g$sd9O<}ON&WzU{{UR+I_%X zbT(^aH@4C#FSdo*C-~9`s8-Ts@s*53>TvMi%t=|35KK=X8egXD_efH@D+R{|!|!XJ z_bKB&nqSkMg7fS=b{(&?o>?Rv#)4L2aH!QmxI&{~= zQxVud`t45ZKq`NfccmLEg_Dc=Pv_nrubqGrK7GFJ$D8BaTnXY-G+FD` z6~qWtZ{<$a<|8~T^nl@B z@ue=<*b@IwZ~PyI;R5_G zCfdf*FOG+;bF=NXhTS_`e~~|L;_4fbH(fLUDqVTjN61fN@=n*}*mdnri5!1gHnm3i zfZeQ>DeB~J*Cc|X$+f6so_k-()KSyxtzWiqaaCZ&>#wP(xVH5X_EV!FKS1k2~=9E%^LOJ%iRwX zb$sBI`jwKLF(z`_@ZA3-wC{tUUx(ZBGra>GJ-Tb}s^xze?0-5iKzv8+$3*4V`%A|$ za{k9T)`OEcX}acXPqZ_w6D|8xjC_xf&ypLqOI5N?E~l-@zi#gaEO%IGq|;J$u@tle z;j*9X24;Q9GRj!rH>HN81R>^251BxS^{7cVtOLL0$77%qv#--&li)G(V_3El^>!Zjg$BxU=#sa&W#7-X9ehtFTH$7ALPP(!p zXYGa$m)953_P+han=7gqg0FqSDFT&8i_1REN_K}oYJ0c`s%hcPP!5b>hWIbhwFqiR z0T54;8V2uKA8Z?`x>gx@K<)=iibc2z9e(c$%*(gi!{{#V)?o7zzkz!+-?MH zWe#{vb)99ojh_e9=W6iq&z;$aXX-+FpG5d^-bwk{G-a=mS`4*$lR<8%-*=?)xxiCJK2dc76fcFZ0>bZ zNKjyb%EfM~z5^lS-FbCDE081s1ew7vEt{$O*VY*XQNCGWf3;Tt)|%7jZYPH|_z&}y zv>Xa7pU<&IJe>9d2^SF5gpb#|IF)N^r@GSe@-5fA45rFd`kt9~hM-@z%p?J}w8$k? zFOD6^z7@rb48pzvUw8y0_;vwxfGiqykfI73%Iy9C%1bIVfN1Bm29k?j7hppCF2rvw z$9?j99WXPZfo|YY^UvN|P&@JA^Y@ovwp$sp_(j|TLD$%R0EpN9yuC&e0c1|kpRm-3 z4WdQ`3!U8n%@%wg;hskPZm9T~@CY7IsUl_$HQO%(Dguun;^!puRHu|Q!8dzMttKIoL^ z>^1^Qo5g|Xe{~L-Y~XnQdRL9Hp7^bf$N3tA`h@~&lrGg;)~C8Dt8gXZhc1KHTiw$x z=z6|=$@j_AvR45Fvxp>xj(2!26@150$8B5xG{ zrvp0me^DZ^ff&Igh>8PpJ-$)^kE#HOR)Ia_1JGK#dGF|FckQvSW0|5=Lx_nIEFRKR z=mheLp*}z(q(qgFKI_>X&_szHg0@o6>8V461~5byfvA|99vxfm-HitjBfq<~81ZJXM!FV)F7ML<94ANW<|1^q2xDH+?Aq!^to%Qesq|6$4Qqr2D&%%DjY z@%0X(#Bee*x#=qMC(oXO44plP^RDrqPN<+-#Xw=v53Lt^bWe*!GJziY1<1FRr0PW- z3XHD@g_5cQ!d5wla*c665F+Oi+Rkzm*&>hkwV|fzI)EFi+G+m;?XEieObqcoD6J!w zztFV*x@ojRodq)?;`UPsx~EigkE>j28nnw@vhe((dBFY{(LK;EQeR?;}2m(V)oG3*qqk67A$nlXf%ib zz7>#8kI6~I{0bJc4snp!W*>I!3P6^v4n)DfKpqx-xa|+v*I$vikEG9SXZX0{e-kQZ z-=XQDy;fylHWndI2nQL$>bwm^^$xf&mN!-JQ*Ngb@%}LVnfX_?K+R}1Cj8iXEr73lx-%#R&yG?C3HL5r zx}#TWb!^2zt>b&`arbMVA3=!NT;F{UaDMt)@Ba+ad$z&9wg%8C#NPv6D&pP=9@cAI zrVTOje77koSHah_DJZv=EIl)pzfPVPt)c+M=#dz{Kj17NU8(e@#n0ZHQ_a~+(#61A zJbG~pq40_`X5s9(#A?X@1yl$y>fqDnUZVpYhhutC0wyPOk4QRr-b#i-n-B?XbJR8{ zK>W?+Um(|5aDFUKv4{vX;l0qIwEDFjN3nRga!*D0J`M&u9oTYkF{Y|-#@g0OgFRcM zgcYQeu~#21S-TqDw1gcWw9aRL?s7>W6`#l7wydH#_8S!Fivqb5u(COQ)vt1!Icn3e zo)D>fZl-AWN?`6knyX@T7r5RQwa&Tfh(D{G>hGWpgHP3X8%<`VFp|PA&bPEE@r@jI z=Y*5KyJLbtI*xyfU>2w4IFvo6`R+ctaTCx7;VYLJq@^UHbe{8l%2Jq(um?s2pALB% zg8JxJha`jUK$>l>V#;D{Ou|$RvGg#S7@R8HE`;D#M4X;CPuMO2f2sw$`;WGo|2FDt zmG z#hWh6;?o1_fS+!9hCR^GmKXi-g4R%auYLP%cLDlP>S(cyy3ut8pOA22zhpD~E zH@X8fi*s;-2r$z_jW^i{JTh?vb5ug}D+#t-lj?UsRTt7N@Sya$>Oo+)I9U&c8V8{O zpy}6c&+>R#KUQkl+xJKou$p>Q%u!TYqI&y9(2xmTa(5oSoT zq7B^VtLFD_`P4qC7?MX-gR}GedcT_9%IfH2G&17I$KP`N8vJIJx3v90CM}dcIev&$ zCt?F^`DmuxhMaX*poi%BC%oK)mAE&F3ZrROKQY}!tcjdSzU#>qZqgE3{Y>uX=$JSy z7=&JVPoo?;?qi~4=X~Ei6z=EKMKKVsMRz`#`0DdV>pU+yb(a@xOni_P2ppO)fGA$E z?h_CGmbG7l*!^PIlCP)XUVw0ZOa8sgQOb&g$7XaP7g$HW#s?qgeZ*qGrfO1Fs@=M$ zRpNWJ)crgz;Ddep$Zz8cvvS{o5LY0CgB53Y;Kg29uJ_B{7y+zJ;;hj6sjLzjbjU3@ zGM-_4=|QBhSx$f10`%TZ@)$w~R-px=s#T$)#P3}s_)6Bf7u6m4&3j{Tl=&#}vLeLK zPlwn8hV+-!W#&?DG7#?J_@m*_q*Wok-`Lg*6ZvgfVH`p7iJ8t)iJB= zA9BLkp z_{itEMHCkk`^+QTz7~iZlX`6=di{FLS$C)TkJ5xc3-^d&mLH5S!w&(vnNcP!1nCJ9 zR8uBRzii`ukRY@^beg)Oz8wAb9A$o-iE_*$@o?d?^vg39NC3@ z;%d9#cFXUMkB#K;blP}Us`pZk`ygFFoiDk!qhQK(*+lN!%gXn?FBF{dnUN5ZoB{xl zE{=_$eWA9L-E-`8n2QC+Hj(w+7lHk1(hdM6dtlZn^kX~=Ye3gJr{~w=_3cuO=6=cu0Uc!@zs!#+s-c+KLp#& zi7ER^{Cxud`?EX^7d1O>aitzy-A-kVq8Zi)h@pZ{BTTknNQr0Es+T|;qixoz*poLJI;S`%s_uar?lEHZIID6? z?<}eK_y5a-`DYZ3v6RqoIAq_;BNWvz@ZwD&-k@*iv?G)(Oc&SXGFNjD)6`*9W*f zlG)BlmJEQPcV&@;ho@3%K=l^*cYTl?M)lFiB46jFoJ=l*` z%F~gXXoY;?%%t?m-aL)-x0i&B{93zmI}~3GHiGG3sazke5+R-}*Y*6~DsorxrID*S z#;~BE>^@T|DW7&2MAhFthO==dxgFO@=idSFm<)Ywi!B7I%Z1s+;{u%8+7Q^)gyMrc zBm5-7zwjd|V30V*`1tZaciyEv+)j=L$uoI4kV~1Bw{@~$IhXRp45<_As$exsdz$$$ z;74#6XKdm-M@xhR=Sbi>uBS2o2*{Bo3fy@rXNXg_wQx8jpI%w*o6i-2^a@h6|ovKDGxrBh<&N0uSk@#?iqQT`M2dh5{ z0#C%@w$?gNEzQEmAdI*pgQeXYD1#MiFf7XUEVcyIp%mXbOuTRii#%;#h(o%@4TUS$ zyz)vu#A{%2)2RQ`S7hW>n-$jH+ARt~qzb^X?9Ff)Ig+mkvE7jK8_`bK&-jxUJ~ zF|i??52BUN^yJY#Z9k}~W4NuAenW#{g2mkgFa@+BqVn7tVfFK3?^bM_Yw!9)lH@>+ zXt?pEy2S#sfaUca?Py)CJ5-w!%Ikt*i9Cuzb zHM~!XM?c>(&XUq+J4r0nMPht1df|)9@$}6M+{e5lw5*Gnd!9ZU-c2~X2B^*zDb2W%l&p}M6Va2-5qji-uVh3u;gfh z9TGp#td{&~P_TWDM)k;glzVe=y;Y(}WrpO`7B!-#Q9+&dU5iEZ`JykQkjq&4;Oi*V zH$}r7eIHX7*qrh6Fh|`m?B%enkm+!$0cD2yeLZ$$VXsB13)3|f(z_lV9`W8z`iEJ+ z(-(PhUQhi`>f&!(RcdpQg#zZf>M&NgeipJH>M}jVriZMV9U=RbyF3t={$^vvX|=XT z5vCBMJx`4HEsh~z?gub!)$5(VfX}p-Q)4EZbC}c|L$<9-h^@ z0!6Ep_#zI$Lk{-Ik^s6$+v~OqNHJ!5w3kimypE(GRC zh8s5U{u%`cv=gPt{%Lq+070 zvjgt0z$QuQlU{c~5erX>l;<|93&J~OWqfa~HNe96uXiIH0LH>=5LcH91D(1D0AQX? zVx}P{2+w&;jkLY~v6t%gYXDVQ1mR_pOK)HtDdq@#is4uU{@xaFVzIY%Fc$y~Tc+Nk zN=|&Qwq&f^a+ZMC^xjkm-e;B*XMM~2BC$=W?dNA?g;ogH>=Q(}gzqq_yEN^c=trV-%GqvT`P-8IV_-8<(0OP7p)btd%oGz*R;r}G;g_Kd*4IR zd&Ag`XqvRLT|-xC#%%=_D&6;8r;!WnM{>8)#Tnf~)V9p@kaEjHPkDjAcyGYGK@AkE z=%}_Q(|9>|_QJcA8rwP+%GOI?Ahe8|1wL!M-+_4!MjpjGVCMROFgwe7_g)QDHCu?4 z^dHC@eS5VqJ{h)mO&+L>)q3*PqI%^r@kIFFWfG)GU#4Aq9CD{XI$261mn(L;{hvu}qbV0h&9EE1)k8hNfxo=#Zv@U;cu6k89c*LGto)C(4-6cs~(kP&-3Me#B* zs>S{MGjnkASyFiep^wU*WBOkI+9lB$(TkcKR*eigYh@&@s2|d29OEavjle-F320lw zxc{X=y-IR$mFja>sPEgdQnEvG+z}n;y?0cZNGNJM_~bWVQQfi0QtQo88|F+>^l#_a zK5jw7CbTP*WXn89Y&`NTIHV|Rf0RJ}SLo><5ei}2K(<`thottHi7VfT)Cp{qXbI3w zA+()$XGhgIeqf9vXYn-q&W>SG%D&^-vIsT{jt+Y6m&4A-Mps*OUPtm_Ho!hAfBel$ zTiY~TY;Gw#?9oTLzu2{)Vf}U_Dvs4qW)-7XQua>2`q{s>^UnlFhapAstYv%DZu*#B z5txL1Qbfhf@A&IUCu{NxOUJXdb<6N}lbUh`!|3%d3Nk)%O(}v+BHpoXT6ZB^nzyw{ z;ihvV6-jXaQX=`YRJ@4}l2iW$&w@RUoChC{na(K>d?ee)i4KjihyLN+sW6K7vvR}P zb|0Kwgn17O5dD2GLE4SSv_kvyQ@ewqm&n7|Qhb8lU!-|Nik-(P@kqjOYJN6u8%2FN z`1r8>_JWetl{XT2^1StL|J({X%jwLq7v;ys9fh-$G- zJDS?BlpWzd*wm~7_8LipcVYA<7+ns^*?xy7-z0B&(zE~coc`zAo<>$Qu!`;ZeEsVS z^4T{>wAeAeq^o#c_~y=FeA1ctoE)peAFdN>wy7qRy*25ynK;D2kR9RT#ilXj;>6B< zc$nRk(mS+yekW%ey5!e? zqm)g@`VjVuQ2u$>GiaLJlL7pAwDC998!qrc&#SMm*V}{xzC@`z?BjZ+gLFB|Zu(_s z%9gIh{7Eu?p3g;R+zsh@S8c^rOl!_-6J1UiK}U{n21vZ z780O|b5cF6p&@EEqk0qB`mLKNg0^=VQ(CP^#TuYlc@_hOU9M+lixc<>Tc&Cf=sKDF0J|)X487XU*mr>EzAbsIcM`Nbo@v znDB4JFp>V(A1t`A`1c1{+rGOYE4kj25-Hw>rr+%Ay71hW?fgVEYH75{Y%}J}mbh^5 z3V2t{!tES=vJABs=_ZVukiRmUWPlKiv3j}=9bLU_OYooF^1nTd2`yH;NJks#k@KT( zw1!UC-`EAt5b^WQlNClE&VRD$HP8R_e`wr8Gi-Ou>%DMvIlWsd5y^7=e6?lw`OEN& zG~>FiPYsRB$_vZ>7>IfleJDC~Pmywz3d%wHwz>fTu%6k?+M}c>kp{QJQaYXV;O7An zns&?w2;9wU(X>3(FUq>;W0$&q;6oGA#V>wFJoDDXC?9C4}_5C!tTLZc~B_X9Y}5~`Q!jIO1p7T%K=DA812eyzk~5! z0+Wlq4d_ZkwP|fm?TpSc1AaXZ;I3GU6CsCyNw;u!+7<|eoSCJCce6m1iu z+N-SxYi9g=C}k$#WCa>AquJrmpoU=Pi!!!@Do?LNJ7XVUWsz{DQOuLyABTewD$^z< z3YIl%x`Iu3)VsbQwMZBV-QXGMnLX`~&sN5WNWBhaLhoIIEO%;0#7b4)$g_QO?=hM- zIbSUDfa|F|kmjj<>-)3y_x-x){kn9O#2^X$zpb)yX%m#~$ym%hJgGFXQTS2)AkVOM6R$$@#a zY^lg)HK-B%)q9vRm+ko44_{*2<>+&0Oz+14F{ACevMp0yUvRCHzeY_Q_T9&L&^z|i zDO`|SD=^1v41_75ggN3sV;CfaEFdu)cVSE7BsPat@ZsHq+Xt5%Kx3&+6Iz3lg*vMN zAm1~6{H3-{1W3~4g^0dCJMw5F{$AeO1fr*k$y#}Py@6367^!d`;^=*voag)A&al$^ zyYRnWPaRc)_)8|UondIkP<@GEAk5l&Fv#qom-nl<09a9nE_pYL7068O(-Ctt%U7cw z{s00}Ne$}^wWpxiGv20&SZ}U3YXN{ElCP_Z<^<xt%JX#J(}i-0i9aWOmMlzf=dk z9nJvln|Ka|5A2Ple*~9aI{)DDKG)s6*F@+Vq|qJ8`D?5X>c&KXVJEr1ax*8mzmL|) zkejYU0X$zyq?=rc*)y5 zKO$3VyM-R-Xz|wa$=1i(c`r$erVXVUN)&!Zr3k&&cRx%rcD$A4O)GBaP0DEn)_5b2 zNt5v_?*eZ70%$gbkDTLgKpMxqu23=GjMARqWkEs10H?{^o7TL z7Gap-*3QaufrGBN`LBC#+&-afd=tIx61@d<1{VVUAH^I(Cgkr=4>*i3JMoqIR^dgz z2MxD?rPqfro?ufhZV}l+GKoIiu%Z06mkd=<2F7C1&4eEs4L2ug1r-uAmuHuN_Lv^C zN2VW2xew*s1)T8)Of6vxlEW@lt#dKMa&z|Ku(upb^cujM% zCZ4FVZL6_ub7I?SY}>ZgxUtz7jnOo2tS0H6zQ^9*_dfgg{F#~KzSlah>lE>Q5&Zj0 z;a{JJ@%Mj)c#2NGsnhDauBcfX3*Zpg5xhIvj2~5bcmn@VVASr!VR8~NQt7q2M&K&+Js2?K4^WC9 zZ3#DH#?Ait75HIgy9e{1D^CXtMr(k5!NLd!wl1Bi@}Wcg zsA1c$Y|+@m_O|qA#>_d5aapvw+AaF9BFr43sB!090K52v4M69XM?zUb-h)?OQY-9$ z#htO_Xm#%e@&~MmiOyO|+)Y=_a0cU-DaB+~9M^ZK?Ms}FPRTo43U7&2fAF5;eL`AD z?K}jsqDwsiWPnZH3RCvIq2~$_Uxg2yoSfQw8k;;GqG-uJ1El|xx#wc2Nc&Oqw!7|MWXxsZ?i+ z#vPdoN4%dSj5XFJT<6wLW*#r{=+3DBc?P09NOUc~c6f3ZG5zCCKI7D}N%*_z)X(t~ zwz$N_MO{LMD;QK15h{ij{oq%HP1~><;`3=((L58V;}@J>n|Ve;41vI03X#+Crc) zv+(V0wu^$RAfTqY@Ln^yOUrQn_krDHk5Ct6aX_za!`Eq1dnso1m8`v^wP`;U`Ow5f zuxNn5I-jGDay#jwedz^_yYFFVbupg3_IreNi0(A2m-nXvk9>^a80=ruA6GxWa(KwI z8<%t6e;Ig@<5Fi7vJvqf_}xoNfEc=~0AQ8?HNuGA z$XEiF@%0Z^K9QF4-3N`ze8c!-RBLgNy9=f)LedXQi65#qx34^1G2!%~ERl)#3-G7I zoP>(W!DDw7dq_qJ{}vqTidejgY1_gt5fQ4%-3Mt9#XRdd+E7LIkQ#~_0fTQEOzMe_v0Jh?&R{W#kN6oHQZK;Kyt*AVR5*F6v~tbq?n+W^B189*tEX z7_V?nfvh(Gk}S@`l==jN_kY3OTAbkNx*sX!AQY&b_Yv(s-lOC(+dB5TrlpRUYKhJc z_N@o-7KvyK*}wgNeYPjG-f6 z*E%DnjLFgyaS=ydN8cy3RKCpSqosxEr%dELj@A#PeyCwM zwooVMER+pEe{3(V4&czp@D$VntKv<6^mQ=kNI$ylsUZArea&q!Cn{2!0{q#7vXHHQV?e{r;!dGb|rfTKU0W(apKvJ@2@_K0QqGAnz$&5Yt9igCmSHYkBiaI|FC*(-QOa|=Cf$uZ3fd~xt?l1pj%M-srDLK# z?pQZw#T!e_nrJN}LU7f(NZ`mDBf(P{TsM-&hyePYTjr0jR3;uUnYks~q|f@2MCX*R zTFPX{NUx+e15-gyq8I;YIwWE{96XudE`m!Pf7}MqSq{;Iu@19DF`DI(L|y`MTBE3z zTIFu6i^835fh7W+SVxo&#kQAE`AhL|j)}5YXX6em*a_Q0e=ks-zo$NE#=IVd6?!WI zC0!(Jdc-B3OhV(p=2GR68pkE|BxCNS^sm&F&bRe41(~h~u0z%cy^mGx6XfNP#kD7m zLOBG2$^1syJ7%x0SpO52hxBt9n_P9Ez$>za`gl&rR#diS!2HvXy5VL4>X^8!co_OO z%B~fCd6p0wJc5FOP5~HV;qff`9ZG#B%sHxr|BQs;QXtql1x8LI1|9F4rn-Bb*S>$f zg8qbrgvQsQyLF?7X|Mh7oBjL4*pCs9PI&4@6jQ~Io!g4K zr8P5d_P~2?e7AgkVNeBCd74geE`{g@RwlGmOggq1{ev{@>V6!i$4a(3JO zYW;=|b&*UbgUG(_Ukbf2E1${CFO#Yta**b<`p|Vos&pf0Av565TDwx-_b?1=ETu$L ziz1_j^nR$y{5=L!+2BFSXzkZlD)gT^+Sh4t8j%1PQaK61sf2Plju3lBUkzwUl&vk! zrK4`avekNvFhk($LLi(6PI&Q>FnO%j_Y>W*d2;71u2?FyIWO> z;L&!^gog-)6%bnYFJScw?$|%n_Z8gwjDor-nTB@^%cjCZsa2{;TR{J875HeiJzK2h zl|EGo+;Emt)5y#1w9u&fX>v|WNh&nb^6b(De?+>aC|FmdQ`Q7lNFypEkqKnF6GfRs zR-GPFBiet0M2GGqMk*7c6@w6|devI!txONuK2`Ec`9+98Yx=}OCCWgak%2c<58+TDdCH`4X3nlUn) z)ylW954x0l;Rqomov(U(AJt1_kv1-LgKeDjXD1ZQ$2PTEujz9}MuXqcLk!xajIIkB z+L(+$bF^z~-XA2PWmGd7Mo;z6L)V%Fb&8I3P)l`*J~L+ce7f{xs@Z;jOB6cWmCDP1 z^CEj6SYiy(4wR*V4qk7XZ{7FKa7mKoAF-O}%=6NIr5-sdqhnfApS&Z&H-2@1Is&I( z{du)b$)%-*LCGf^+9QO{VsP%zxTl_hh(p+>%bVp^gumg=w=}I_6mY}keneky6}kuD zrvUmR{koxX5`JuZmn)~Og+^dQ%Cp4BM!2vcEaeZ-9z64ndEZc51frHXvQ$aNfBs*$ zswik5{Y1Ck;e@}*`?#anZDV55YNKeBdph@@`D2AqR8jY8$b);yJ(Y{`!XbmSN&B-N zN(*r5T7EVX4&FRJ+E7boXxq|0e74}cUZID_*TSqk^=9NP_Sp@b>1be#tH0Q205@&Mh=P8_B^vqLa1UVOjgm`sY#oxb9kf{w zT@P(6E`^GGyJdlvGE2u$m-)R*3MbC$MNaeH9iornP&N!E7TM|r>Mibgxw-ladtOW%! z$WuI}$CtmXa)!eiEsh~etqc>{5L-#^%Au=Cc-wqXGz``C=gwec<4Ri7*WO*YCc)A( zhEDmGpybozkV`FZ)32=BRdxypGfZlabuRg_UD`>icdEzCm+}U78cVZaxF2i6qX$)= zkff2l&jdA4e)-B80sXyRU*(&Qam}-6r}(m~P8g)vCr`dl>ziPDgXqhM37ZH=sz#L7 zqtnAnvohkF2|}jLMgeb=tOy;xNiS4tsuDlIe~SpNTjVm4%c4ixviN$hbfG;2?gRHb zSlgN<)xlwxf<_Kg(1s&n0uQkn|I+DO8jtr!rlx?nsMQ*Y&z#LI+IFu@Lj+#J5h)$h zTw_S~2gcvh1b#%jj9Cl`7#;c$&6bUr1Zv8ro`{w`7OFu){YthdOfzGmDS(v4X%I5v z)q`B{NlX! zT~MJlx}%6vcmoU&Hu6HYND-0NdVM&bkHfkU9w6bfpC4P&ep%5?!pOe=WLA)qGBj~r z1leHsQWR_QXz6^q^0EU`m6xw*C{6V3q;xm8p`rh&I_5+W>DMgfdCn6;CUgHOt$)q5 zeabzUN`L`MGn)X2H{v&pK7*N+eT;iJs*n9O9jQ54`2+5MuX`E!p%|Pk556v~2GoMa ziT*ZK+AN3~MBL^wn4HRc+DjfG0w>sUA+1$1AnQ7HYX5@O&-F-$v~}YMlQ*zh?2Kh? z;u*Ah*V;AVv~?NjPz>npS1o!JxaRNzP?pg3FU7aB`^mwQN*W^r*h)0lGCJH}w?EfJv~t!YbX0 zRp}z`aYKOth=F=rn;!Z09&z78bWV7RwA|q=PUJBnZ%c7cc;HB9(2!5s+jA;l1m%W! zBJnf_33IY;)r}|YKA@m}0oE12I|u20BH2-s-p!_Vp2++dMN7qnKST$N*-bSvD-5zq zeg;=C%Ss5X%S)9-pOuo);zCv2`9s9Y4PsR1+4 zuC=1UQ?5Bl)KaqKlqZ-Ws+2zxJT+Pfsl5*gc4>Krac&i4o77lCR06y;kFoz5xTabE zAp=*|Efq0{x(~|Qn*9x2rggTWBz_A?cwo)Fm;bzL(p*|9H3e>qsM{YxY)n>>u|klD zXzEQ=vFasK0l$rOP&oz$cH0vRMN~(kb54E4K8$R@J+9vPEB?UM4Nw+wMcC_%q4PX*`)Q3!daGvGhu3l$?IS<&4VfN0(RC*bVwYRbf-c4Sg`*s5J>>Ynn02I zIY2A^YOp(c@lTZRIQJMsi{-1va1jlV(!Z%n@Kjd$7K3$@*`^K^5I zsCh3u^+m?r(v#|Wk5E(Ul%nVOS2Fd{hExIEbOvleVPmlXi{`6TpI{ zb6Gg`V}Zd81Zk=1u;$>?{;HK96&LO05CjD0jjV*I2DVOR5$VP}V)hWmCueKXA(2%P zCpvNkbk3?R4Mf+xbEpYzp$u|fSj?#{MHAF~2wd)US9$Nn$oRzOC-o__ zj~ve6V^H??mM43pw>J`pF}p~U2Y!$qbZL#ssInrm7FN2PPhy$;)xiS)E$927nIr%Q zP8_P>E7UDSa&ZJGm6~c{M}I~p3HA(_q)SdR`2hs2v}C2_I^) z6>$Z{uu6EUfQi?YV%QyB!W-YIN7MV=Ts)|Csk>p_(rys$amJ2K%mEMN(8Mi}wFr02 zh{%pIZ_9+;qbbUKr?(g!1EP#8gfdv&=LJvSDq|wQ9AB{uyg*iL25501)Y3v$ttBSm zk8wLriy9VjS_?f$M7i1_PjGP*BXg>}A!{O_X$XBT0uock6rP7mrSSECGV+^CgI#2h zOFL%W%!hlXcoby+A+!^AR zDVdRAfOw}>Kqzt6xbrP@$z=3V4r|`Uy@ARIG_NdVWMG4hxVy=HO6?@wc4R3`BJw?)9Te)}a<88o9 zog27zp#HWBX|F=@XKMd{*yY#+k?Bch-vI8_Tfb_!d<{ISv+0_#GBo=Tbk=~e#pLsT z!E_!WBgO2Y&_Aok7Fr*S8TJ{ZhzpA3*C(`f!);RGyUn^{x=ohn@uu={en}1)Dp=g{ zG32K^L&Xr#friG9zMG82*YlXyT&}Gl>BIYebTp-xIT+Ig6uBaJitk84LrF>N&|i<- z7@tj(bI~*PBAPQEd*3duxmxRJK@(y?9tMR}YTP7qSJI}{MVR>wf5PQtY(|5uA(2ZW z&vg@Sd31;+)DBkBMmKr!bVMT`I!B|R(SEHz7wql2AxUrO%f58|c(O{^w21eqHoTI- z?i`tm6bTJ~ zoy@xo8@ZE?=2cHJdZbtQE5?aLWI8knVaO-=aIDL4f|PDVv;e{woWL(`?jmM7UIygN z_7F6pAT}m%8`Qw(LtidI)nm+|Y~>g6JbA;5zRX0loUE-#Pba89$zRAi9N@la60OS@ zSxfqtR-Ae2ohR)e@ux+36WTgV$qHH1Ac@bSb|6W4E!Fa&TQ42~g^;L$@a$cGKmNoj zd9X%cJoAnqmk=r+nQLvEvPE!9mahemZQ*g1bCfT=&f(_$TmpJgmq-k{K8#{+YkF0KK&vjmo@WS$__3K|5X`1( z95b>I$A0T_*ie~7+qTosPdmxb{_P&Fsq#rVj_jQXX`1I!6D+n;yK6)UF7EwDZUi)N9p0&Q zuA%dq=vGdD_P`APc$VxpI%r|E`<$H#j==v#kD|li;0Nbr>jWK}Sy1Id+F}3pulxBJ z6XH-{PxdRl8fIb&f6)>C+IDK~bopoGusD2D&#LS3xyK!ZiHse|0ep$4KPd`DATMmJV@XvFKvr5UAjOKO)ehi`J`& zwd2|vwq<`}8EX4Ur1!!2M>3r0q-~Kz+LtS4d>-Y z*Jrs~dL(0*vd>mWy2d7TYPmz)F-JpL!MY`komk!;xmG<@Y98rrU`N*_}4rWj{Hp0y)R9;xTeK8#~gpZfo#XKTTPDAtN*Z+w@`Ivzy~VU>vCBWdhJ@FKLyS06NAN}7lOCW z-%IVb6+l6H>nR^ud~i?#VEp@0O<`7Vx%Hp>3@KQI5ff=%;hIqo*epAn>ulGh2%m+t z1eMOpXl5(Z)8=R*cnmYz*=%p8g`YqUUyG0$mvErzj&kvn_C9R&JM}}A(mSP8JQa_9 z6ydtOf2}xX+-NZqrV+J^pd@CTG;hF%8eC%?VV_+#D^MKd=HtdWSV#4{{FbhLgNm!c z=3I7m+0g9i_CjjFsVky$qt}jd->r)u`VpOy$^J{M__Lfmm)OCZShwQ`^}x#+|l z{33Q@oLUsirII(zQ@fO$S_;e|iWs6}fdq?+m?VxzYumh?r@*Z@by&3T)m`f;{Cyxw zJE_IyW04^DoWi&??^mHbYLS3E@eUovo}p}tNSYQ1p zY8{~%P`ZP@a&FH`n9oiG?7BspIGlc<{+w#0*4$Rg=$p)!Y>{%yi6jwn=R_tb(559X z@TU^fN94_)Ok{Vv!-f|H)2@T}(s8z{p0&u}3q+}yKZgTzOI7xj7KagYXfOjH$Gm?Z z5LX86CyzA}LAEkZVf*S~Fz|&!P(g8j15bYtT^~dtAWucvhHKc-7B(BR&HAjvF~cvV z9D|_ZXdLI3=aKXXl8{1kKBa=;|0*lJrVVM8ZJoi3C8?ahU+b0&O!I%Lu^+ z6lhP_9JKWng|ALTKiOO-q^@)wS@6Bpl!THG(8}E25KJ7Q%zm}%h7gI(lu}wo;WXY? zBhNX2)brd&3o-A%HF$Ag4sKBiM(O8mRWI;$dm<;>akl1GBhpXuUrq4AsY=Ck)E+Z$ zt)!x2{!?!KpO;|3l&lFVBYL{-ML72x4pJjFUHuwvg%RB`0%LvGsT(uTTriuXnXfJPGg`Lp64zh$)w ziP4L;b;i+}C1c58zh0SZm0p7fNiUnwfSbj|j&%zMf?|)h&BM0N(Zkzro^gO{N~1%+ zb;I~Hsx>BZdTIWg(z?B>x~SBBbD=RV$n~he3S#4@9dJi;zEc>Gq%8%F&R96RiW!k1L3v60c2PwpvnsgK2r=m3c!ndaLS+pLc+n~Fn~n|MsM znz(62jGRf*+{{yj#S*!;c2+L@6f3091K2+hj_5TaM~#-r8tX9Znv6PDN-(dakzUjgGB^2|b_iS@XYgcBHEv1Uk7bPv`*xmMZr3sq?PNdl*C1bjJsj>Z zyOW_Wm<&R~ot9q`=1$(g=_~n8fRj9`4^AH8tI|V?F@Dsj%3ov&ypzT3Iuqk)YQ2CM z_6(uHozg!!D)>9y!Hd-7Z!8@G`vXE&Q~tw7U;+ccP3eqt z)**fy87JY~_ksV+n^WyuegYF_YRz$y%l~LW|35h(BO(-7+uC{cyK%%2D^BZMo)NUS z#U|EdEk!e+NmXy~N6dWK(~s}9bSyN~+uWdrk(sDWc#n8hju46YQa3iX+4v)mZB4WN z2x$)E;l2xBX-^cJRg4m=qP~VY`8hxHtx0;%_1VG?04dNzt zs~0&{GHTi`&rAYZt4k{8)3w5EMd{`}MN`Uc!nQug1X&Ctj>~SM?Jz?Wj#}?|sghqs zo7UqwSdOuUwQ-fDBI3$1VtyO;$GLd6Cp!Y|Du(T zmEh)LHEax!gpA7k5}&___qx^Ypl0c`1NJ_~lyMoc<53p@63K{W-e-vJ1DsRYX8Rdy zp_S6O8dy|v%SAzN=3EaACNktS`h?0!3h8VORv9U(ArD(I58E^&7+YCqae5oGI-TWX zhbNbRbH|mO&={_&-Zt@%O$zX|VQ@z75W44*hK~QqgQhpkM>g}94r^zwQF{HddB^L@ zIQ>xca);rQg045=1yPX97P>A2s{37V34aEM>lDQ=46LzJp8PcJiB_dY2>b1-ozd}7 zuEu%$JYoQ(zm&|prb{X{O=pC;0k;ID5No&B=GG<4(36oxf($*jSM?~ z2ENdHD>P zGpt2%dY{wMRsUagcuREtE7~*2J$lK@{vHI_r}@Ngv7f|3%E(k_K(%d+`u04vzk<1t z1+}SYM8EikLxf@o>LJDyj7D=Bu{qh#8R_47(Xhgq9}$Y{CIX!@cVFGK7F-ek`>ND& z!{`w^w57BBgoJEl(?N5GG}&%Y3}=k|YH+W%Yv7ML4e+&W3}+tHk>Cn`K(Hf?)NVJ* zd5yToPlgeG9z>g})JMiuaAW4uS`&;uY#=ktca(y;)6d*O%H3hlqN=OrOHNi4=`)kN zYDB>QtH-i+(Z9;jkp{5PtLZro+f(<}iZfLLP;(&9^mS=yM`C}D14vc;BQ(v&uar7f zF%KAnx}u>1GwSU5?77EnR;Ki!&-L!*fDE>Q^Fx$TwPsVHFz=!FH^&yanmQV>V&M?C zB|lWhct-+Ms%cY*G76OA0tpzIAFy5LEJ#b5Co)m$cajZ5Cs9bdiNkWbD|vC>s(m3f60#}ednUNx zGoaH=Q=?@&UeK-8DwvTGOPXo16BUDw7xG=o9Gj1LW~E!8O;5FPj&)Z^>1ge|Ionq@ zlPb<8CW1Hl)fL!;u6`i$U9Y8_O29$g*;X*dwW8j6sB@RvPU6ufa#F@`#_@jbIYJ2} z(R1bRg~ltm($&I`5qmXsun7psZc1R;#`g%AXNu!`Q}Hs;Ec;E*0iih^X392W=$mu! z<$f2O!l1+-ya~$}rONi>;E@5Pv~l7?FZq)D zdDWjrr$aoMMR7no`I(I|B`U#mV3Z?wu^o=bQQO)I17ofE{F-$Zru5IMP*5GQ9D{ag z8nl9|LX3{CpS)pe)I%=Hz?F3Tkk^Q;EFT0a+G=Nje8dI+>z77MFQdxK7)fO zS6s|q-)kT3>Vw#NB_1qPvt%w#5rT8l>OWg^5 znHeiRTChe%o|!0${C#W#ew{i5UTXqJcKnm7d!}CQod#iCo;*shK#bgyMM zz@pYyVAZ}Vd-T}%p8J&U|Kzt~Ota)+p>@L4ufPCv<<%Bi5Se+fT~A;5nO|HN+KIh3 zEI#o)lYb@b!z+sTH3lL0>bh^lk$$(*!CBnE%U2w=GdD>gQQ%FOAjfG5n1bUeqs8Gc z-n5VsfxOS`ok_CjWKfry^4;t?u4Ii-6E@ga)3vr^R^N71#M$@2zWfK71_|lDm>)@< zY7LGf*xF{8h+i)9d&SloEjYLA&Y${s4iYtKr_K{SFQDW&6wgmM*+}sV3%QRo%>gB?gx~rs$+$<8(r{!q9b2 z`X`Nck!I5rc$W~LKROVsbgLrF1aJ)`g!-S4?eL*H%U(F!%u>BzFOzkoU1MfFf3MMJ z^fPQGwASr!5%UBir=sDz_w_MGNN5H*tc`0EAulQ`2XyD6%&^cZ{$4eCGBnFiUJRQguUEGpg83>Zy%Dz3R7=7qo7m*hXL9+rimTF!sx-w5 zQ;Vd{2~0EPWDLd|hNZX?>AB)b9qku-E=lAMP>Wh#NgifD|O^E3!FfTLSV)4Z@Hmo*QIMsVAGPWPrT! zb$PhZR4D|0W43#6+=Y2nh$97mKq8CM?t8QLa21Dd0$Zv#15%;!VAZL@;r|*6M9_8I zVZf^VOD3I3#>ZWo(<`w%ps*n^log~RMc^IFHa0Az&XCQ^cmt19Ic2vN7Gp`yZ=DvQ zn@D&vAgNtm4;`AL_WW6_cAf1yl#O5XDUHz!Lp6G4#c&$F{@+fztna37x=m}TbJpwo zR4PsZu%y_W<45^g=C(s2j5_Pfd2jpUKUC2UMeUhsx>!K$zRshS!P z8#mJLnri4FYyDncR+F(RQRxx5V{?}xv<3NFHeZ3&RQL(0crodqGH95zf=KS56jw9% zDrgv>ji_7{FPFrdf-XeoNSp!gpq&r8okp=(7wAElLyk8gavEr__VUtXq-nbS+Q-`&Oae*Np8{1756G7#`d`U)bT z#uXM)wEx^J_JhVJFw z@1#Nq){EpWDJu&TWi@!r+A$OAFsOmRKx}0|iv%3O^v{V<+^bVEyqOcU3D!Z4FmiZ( zvRTvt8hFoOm^LctoUAB)zAVdy*QrmgV@7!fXSAp*BA{?U87n|!@Rz-SJtLVOq`P6P zUPb11%z5XLY?*Z7pM5q&Cz%cDHFa%H_&6k(?}COO!V9b)zhg{4g?U#e($uo7-USVy zU4_OB6PPmYw_CI9+dk{&f{MP*GPsFrWE=c*x;40&+@KK*c`TBOdlR20$mc+u5#omo zxzTXxw^pOVA#1hnI8B`T{Vse^+jX!SsR>hoxLW;0HzpfCj3!K2i#E_+V`#ze^bEJc)gny0ZQfYl>m&+5*=LE|01tRbI46Q(6;Q zQ~b7)pqzO?->ygYMVCUry_f#^4&bS(K_vqFOTfNDaYr= zdZgv3B!o*=;P54bsY6xY=!&j$HBt|SEFu%6Jd|75`mjW9aVVJSL`{UoJMOjxzOQTZ za*u%1E#%W@<^Sc%WfV{fV2?jnvR_A$MvK+kOwC3*^kQSZjKpg!2^M>!yXwW?O*(n$@ysBayg!>N^jCAiJ&$o_hpe>-Q%3SDbp(I4s<& zxW0%D3`6)7j2iqe;E`*R2f8Xn9Bay7CKb=2x1ZY(aBa zfygTef0VpR)kWsBKmT@&;_93)I2^_;wQJ@?u;E3lY94^$G^2{k2#Ch#WSKmy(-o<$ zo^0wb>DUlLGg0)g?;I-AsI6{Uk((Sp>-D@lPUnjL5!*2!d2TjU0uKb@V~K}%kN>-2#FH833LJrN(a>}T( za7OzghF3pHZ?KC(TED-Z;wP$LJn;Kt;+|y;62X^@s!9e~OpPE|qbS&3sZ2z3BdWOM z07ksS4-N<>$Qy5xJG;vb)LZ7AsL5Ue%0B)Ix&l%^L@coEMk4sxlSy&(PQbak=TbZf ziV|aV{6z{ADf%!F4OdvzUJi1<7Rnbv%YsANSQ<+o%DstYo z58|RF)+B(@qw`T0Hn$P$?&H2_2Qr_-C2A}l-GYBM;kwhXa?G-g#dh3EO4}r@&~ByC zC;p#(-#-^`Z0rEoUCZ2kT{1dq_acAA1@~!r$e`7cji+*`DPh~HFu0Na=ZoQhm0qJH^Y~Ni3(u{S9_dZ#tv{)Ed;HS>rtf#MRO6hj%*CTw#1nL`dhl| zAjZJ=u04?Eu*K|(Rn6u@y+)>)uwnf*n2=nPW-ofIj;?n_ou1!)+;U2 zgAo~)V9S=#d-(lnxBvE*(E^SCg!J)X@J~<|OT8AO^(W8Em4IFHM%|*7! z@{P)8c1h|bl2x6K*WTc`Fcu+s4EfKu>dB15D30GAYUy`kR&u$9{>9;AB!DJd>cHWXa~Ui-99w9A z&=u#Qti(MfNm5l?=s4C|skz0XGtLT`t)>v`gmS={`U^7>hD_!BjvBmF@O67jhHp5s z*_EW0&xpprcU5N*2oJBI1nDCD(9?b{3oHbBd=4_h0;6ihEz|-1F;wBXGJQBo5}hp-6lQl9D=3Pfqc4Z11I>=B-vm{V^pe5)_dJUbDhk2=5T+1x<}k)-OpBrsXIs zX|&Wk^uZh!keG-;-IMtZXFE0ICTTSpDUj(uqZ9*VrB``Jh;ei#`B{wvgJ~p*S;N+G zN?4Ed{jNk-5fjtcIrgao09ic6=S+vF`3e2LBXN*Epgyqa4~`sh z(uMhpoM<4K+j%|jNC}6n_t3JrT`CmE)iWyXOB_iVYorR?XiBr9p&Z4>ifw)yfH`pBmN@R@rAt-IgdDn}Zw_M~T>~ zt{|$2MciygMyw&%Sdr0G0h;NFnR{}k7cf(dx8}j>XTpqob}#=I_6wSBz3~lLp9}Wm}npb>3U(vXp~fEM<;RPq|6TeJIi!p!*f(8dsJg!VZB~=bE>Eg_FnN>;;l_3V) z?^)vl5#fptg=d~p+kVhd=ToZ>^dBmej0nAVNF-ZLWIO!YcHo_+0#hp#t?pJ@-cL5H z;z?_PeY$mq*RPd=^*xAGxIEC#%ito);rQ>Z1rK#op#uR?gN-o3cl92W(m7+us58}3 z*bu>bHF#tmO=Nvi{fLy&BJ=aFgq^|Ed&wkrUydSCQp9qlf9N&dXZqWCVT6gb2zQaL zOtg2^ydt(>`%uIQ8;VZ=11u{}T- zKdx5Yp&s4mhID4Axg26WhNx>$p2$v3Q!re#8B)HIfLwn!>86?x82& zNwxBE`JxCeC%Psr{}X)%N>=!M#(?xplLzhP@)FKditR>*nf|Hop@ckHKzQLOe3_}> zNLXWnP7x+;9@`qS_ux#$AIXazYl1718t4N@;PG$N+;}rkiKp8I0GXUiKkMY;@uv~P z^ga}%mX}i|snnL8Ub~qGznFj{vr2yH4Y{b;b3%`=UC2(2wN_&_h7s4a;Zb+m!@H(J zc)W2XBQ95IY0yI6Q+yUU{k+0BSj+Y!g%LM#ebfLpj-1WYIW>g_t&I97^!CO*(GOHJ zKq0Cp;_^M;cONAp=_n;KyzOTUg|)cxk z#|0ER0_=|m73#!^vd5mi@5E_0Kx-(OD_F4jd}pgVUL z>2N7dunb#aOmIc&HN18Mo{Xvh+%{092FtV3b8J#EJalF*Y4P!A8ucX#cRc@D{}cn) z-@duc=^Z2H`JN?CfGegOJ7!+yGPl2ff5rwnIftI#d8NPU2sOK6aVoeYribaVkc&-o1#^d+kG+w;OAd+v=*<<(nt82K=>j*xd8og| zz%6lmySNXzL8_%R969c_KwMUOvUth3G)7)%LAL*W)K6Owh!!|lUgR-2C# zMzd&QeTDf`*Yz3=LpIv^(=tviq;l9E93KN`qv@r?qB)2z%nVpyB9CJ5Hy!SHv>bWV zL@-UNTsUnvZ0I7eqlC(8tDuMMrW{mZIo9mi*s$_(pZ2X0#Z}Y5WJh*6O+ouSpe{F7 zcO~N8O_Tq%z0SUOn8>q&5#$QgX3ox*)kID6S!AHqLsT{dD+_j>q-8OL{O_Ie+GHCxb;sMo2QGV7`jUz0p0?H~X)^`X%7f?>G zEA~LIWsTS{4!Omdng1o4@C`6>L90w4bP4l{!*?;td)a%2O0+k+!9nlOpA7yeL*gJ4 zwdO;Pxyt|XPIWd?BF)hSBb)4B0#-UkBfIDe!ZHZv^ttgT7ZP|tTYc8j13a&gZ zd}Q!J4S&A61X&YPg>2a$gqmT@tQ)sDlN%|3a|2enC>}UTRXC1$duW8_KaUVLx$_jA zaG8d7|5;l37UI_NSHwZRI`U>A8%Ijx+uK(31D+&C9-Ao5{_RUgZ5CrKtiZ1s`Ek(E zO$YemGbm+EAB`2OUwLAwY@mLF>i--UeY`wa{y3lHck`~^Ah1JKerEh~NYQc}Nq|pc zLdKocOm_mdmtR42y7SS{GiN@Bo; zV`BTLVa~)v=_*dAJQ$yw)#Y(A9R_h!PxFnjVFXuO0&Z)@Qz|Tb1-dN+S4a&A`l~&v zAqU(9n#)s-xL%tS#mKCtpp5zo?5lj3iuFHrCXMj=Ew_H6e3gqGe1WrgY9j( z?$tGIDD>3z9T;RmFWf%VoSoMCD`C-}cksg{2*Q&nfX}DGFg3U#7J?I=C($ih`bqN; z?5lRqxjkY-us=YMKw-{>c(w;&)*Q219j-?IKE45bh3|Fyb+N+m$B zjF{j)ou5*GpA}kkeTn0JHQ=OcQEaPnTNs_q>kNYZOHAod2yM#$XRpI1{QCMy%}35% zNnr9}j|DBOqP-rg`t7> zcvV*KZ}DxM{_5kDV84sy+cq-t)W_wmEb_5JQq93Mn~zC(e}nAlBO((!Qj@qGDrH$< zbI>TmJV=}eK`d@jYGDr&Ot*7=@HR#YQk*A8%~k+SX9I7sDE_gXGik0CJ0coz>H%ut zc!A+48TKM!Wh1!ND}mB!em#4X`Q~?UIu?e+4_u? zqup>o?DYKSqU(1Y{**pT24x*2j_|zN0P5jx10Fy^u@ud|{3&@J z5$5OY;y7Zo@#VJKEIGnH{V53AUvUd~Zc+W|_^VGO2XX zU+~A*$mcN?4_$45=O%5NzWdJ8ZTEg{N?v!W=!*pr;he$|Qv*D+40tA1INx00;o%{< zz`Hc|Jo*+c5qvyC?bw1XdS`AvpSQj2Eo-u~dJJ+=g`C3>7WuL<`g!MUfBV!u%zRqs zM2EH9Wxxa0*FSyyNIb6Qr0VoD0=kZbLQjDsr-!3pufY60&u$AP>lGR?Tz+3&biu-* zOs$|fg0LGDE`SSB;O$)UAJ=QT%MrGZrO}tMQFMb(4%dcdeS`~?CI@|{jXqxv37tnu zvM8w@iIdQ*AaME!Tcc?w%9cwc705Y%FE9sR;e$3PcpK6s0AdRP=2kLiqs~V8dkwhi@DXU!fS`-FM(W^AUcF=1kiQ Qa~XiZ)78&qol`;+0OR&_lK=n! literal 0 HcmV?d00001 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 10726c26d3..14cda97389 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -38,3 +38,4 @@ * [Contributing to Snowbridge](runbooks/updating-snowbridge-pallets-bridgehub-and-assethub-runtimes.md) * [Governance Updates](runbooks/governance-updates.md) +* [Test Runtime Upgrades](runbooks/test-runtime-upgrades.md) diff --git a/docs/runbooks/test-runtime-upgrades.md b/docs/runbooks/test-runtime-upgrades.md new file mode 100644 index 0000000000..7f8b308831 --- /dev/null +++ b/docs/runbooks/test-runtime-upgrades.md @@ -0,0 +1,191 @@ +--- +description: How to test upgrades depending on a runtime upgrade not yet executed. +--- + +# Test Runtime Upgrades + +## Overview + +A scenario that frequently occurs is that we need to test a Snowbridge-related runtime upgrade that depends on a system parachain upgrade. Runtime upgrades for system parachains can take up to four weeks to execute. If we wait for the system parachain upgrade to complete first before initiating the Snowbridge upgrades, release cycles could take months. + +Therefore, it is useful to be able to test system parachain upgrades that have not yet executed and then apply Snowbridge upgrades to ensure everything works. + +## Steps + +In the following scenario, we will simulate execution of the 1.2.0 upgrade: https://github.com/polkadot-fellows/runtimes/releases/tag/v1.2.0. + +1. Install [opengov-cli](https://github.com/joepetrowski/opengov-cli) +2. Build the preimage for the upgrade: + +
opengov-cli build-upgrade --network polkadot --relay-version 1.2.0 --filename preimage.hex
+
+ +3. Convert the preimage from hex to binary + +```sh +cd upgrade-polkadot-1.2.0 +xxd -r -p preimage.hex > preimage.bin +``` + +4. Determine the size of the of preimage, save as `PREIMAGE_SIZE` + +On Linux: + +```sh +$ stat -c%s preimage.bin +1567371 +$ export PREIMAGE_SIZE=1567371 +``` + +On Mac: + +```sh +$ stat -f%z preimage.bin +1567371 +$ export PREIMAGE_SIZE=1567371 +``` + +5. Compute blake2-256 hash of preimage, save as PREIMAGE\_HASH + +```sh +$ b2sum -l 256 preimage.bin | awk '{print "0x"$1}' +0x15165c85152568b7f523e374ce1a5172f2aa148721d5dae0441f86c201c1a77b4 +$ export PREIMAGE_HASH=0x15165c85152568b7f523e374ce1a5172f2aa148721d5dae0441f86c201c1a77b4 +``` + +6. Create a chopsticks configuration file for the Polkadot relay chain, substituting the values generated previously: + +`polkadot.yml` + +```yaml +endpoint: wss://polkadot-rpc.dwellir.com +mock-signature-host: true +block: ${env.POLKADOT_BLOCK_NUMBER} +db: ./polkadot.sqlite + +import-storage: + System: + Account: + - - - 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY + - providers: 1 + data: + free: '10000000000000000000' + ParasDisputes: + $removePrefix: ['disputes'] # those can makes block building super slow + Preimage: + { + PreimageFor: + [[[[PREIMAGE_HASH, PREIMAGE_SIZE]], PREIMAGE_WITH_LENGTH_PREFIX]], + StatusFor: + [[[PREIMAGE_HASH], { Requested: { count: 1, len: PREIMAGE_SIZE } }]], + } +``` + +7. Use these Chopstics config files for AssetHub and BridgeHub + +`polkadot-asset-hub.yml` + +```yaml +endpoint: wss://statemint-rpc.dwellir.com +mock-signature-host: true +block: ${env.POLKADOT_ASSET_HUB_BLOCK_NUMBER} +db: ./assethub.sqlite + +import-storage: + System: + Account: + - - - 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY + - providers: 1 + data: + free: 1000000000000000 +``` + +`polkadot-bridge-hub.yml` + +```yaml +endpoint: wss://polkadot-bridge-hub-rpc.dwellir.com +mock-signature-host: true +block: ${env.POLKADOT_BRIDGEHUB_BLOCK_NUMBER} +db: ./bridgehub.sqlite + +import-storage: + System: + Account: + - - - 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY + - providers: 1 + data: + free: 1000000000000000 +``` + +8. Run Chopsticks + +```sh +yarn start xcm -r polkadot.yml -p polkadot-asset-hub.yml -p polkadot-bridge-hub.yml +``` + +A verification step that can be performed to see if the preimage has been added successfully is to check the `preimage` storage in the chain state. The authorized preimage should be in the list of added preimages. + +9. Execute the upgrade on the relay chain using Polkadot-JS: + +```rust +const number = (await api.rpc.chain.getHeader()).number.toNumber() + +await api.rpc('dev_setStorage', { + Scheduler: { + Agenda: [ + [ + [number + 1], + [ + { + call: { + Lookup: { + hash: PREIMAGE_HASH, + len: PREIMAGE_SIZE, + }, + }, + origin: { + system: 'Root', + }, + }, + ], + ], + ], + }, +}) + +await api.rpc('dev_newBlock', { count: 1 }) +``` + +10. Advance a few blocks on the relay chain + +```rust +await api.rpc('dev_newBlock', { count: 2 }) +``` + +11. Advance by one block on bridgehub (not sure if necessary, need to experiment) + +```rust +await api.rpc('dev_newBlock', { count: 1 }) +``` + +12. Now that the upgrade has been authorized on BridgeHub, we can execute the upgrade by calling parachainSystem.enactAuthorizedUpgrade, passing the parachain WASM blob previously generated by opengov-cli: + +
+ +12. Advance a few blocks on both bridgehub AND the relay chain + +```rust +await api.rpc('dev_newBlock', { count: 1 }) +``` + +14. The parachain should now be upgraded. + +## Caveats + +Some polkadot API endpoints aggressively timeout connections, causing Chopsticks to die: Comment + +```sh +API-WS: disconnected from wss://polkadot-rpc.dwellir.com: 1006:: Abnormal Closure +``` + +The usual remedy is to restart chopsticks and pray the API connections don't die again. From 0284e5a448c1e4071f78f2910721ae05ff2a5c95 Mon Sep 17 00:00:00 2001 From: Riko <49999458+fasteater@users.noreply.github.com> Date: Thu, 30 May 2024 11:28:50 +0200 Subject: [PATCH 4/9] Update fees-and-channels.md (#1213) fix typo --- docs/architecture/fees-and-channels.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/fees-and-channels.md b/docs/architecture/fees-and-channels.md index 4ba49a73a3..3f685bac45 100644 --- a/docs/architecture/fees-and-channels.md +++ b/docs/architecture/fees-and-channels.md @@ -50,7 +50,7 @@ In both of the scenarios above, there is a common pattern: Parachain governance therefore has the responsibility to ensure that it has enough funds to cover costs on the destination network. -This can be done by selling collected fees on the source network for currency of the destination network. This currently a manual process, but should only need to be done a few times a year. +This can be done by selling collected fees on the source network for currency of the destination network. This is currently a manual process, but should only need to be done a few times a year. Parachains can use the BridgeHub [transfer\_native\_from\_agent](https://github.com/Snowfork/snowbridge/blob/c2142e41b5a2cbd3749a5fd8f22a95abf2b923d9/parachain/pallets/system/src/lib.rs#L503C10-L503C36) API to transfer funds from their agent to some EOA account. From 9751b791093e4a193d41b559d120825d9ecad79a Mon Sep 17 00:00:00 2001 From: Riko <49999458+fasteater@users.noreply.github.com> Date: Thu, 30 May 2024 11:29:20 +0200 Subject: [PATCH 5/9] Update components.md (#1210) typo --- docs/architecture/components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/components.md b/docs/architecture/components.md index 6c9e39add3..4b7f381efe 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -21,7 +21,7 @@ This [pallet](https://github.com/Snowfork/snowbridge/tree/main/parachain/pallets 3. At the end of every block, a merkle root of all processed messages is generated and inserted into the parachain header as a [digest item](https://github.com/paritytech/substrate/blob/46136f2a18780d71542ae615565703da754b5348/primitives/runtime/src/generic/digest.rs#L100). 4. Processed messages are also temporarily held in storage so that they can be queried by offchain message relayers. -The merkle root in (3) is the commitment that needs to verified on the Ethereum side. +The merkle root in (3) is the commitment that needs to be verified on the Ethereum side. ### EthereumBeaconClient From a200ea03019fe3de94053088a838444bff093d69 Mon Sep 17 00:00:00 2001 From: Riko <49999458+fasteater@users.noreply.github.com> Date: Thu, 30 May 2024 11:30:02 +0200 Subject: [PATCH 6/9] Update governance.md (#1209) fixed typo --- docs/architecture/governance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/governance.md b/docs/architecture/governance.md index 233ad97541..cfe54d04f1 100644 --- a/docs/architecture/governance.md +++ b/docs/architecture/governance.md @@ -10,7 +10,7 @@ This promotes decentralisation in the following ways: ## Cross-chain Governance -Our bridge has contracts on the Ethereum, and these contracts need to be able to evolve along with the parachain side. Cross-chain governance will control both configuration and code upgrades on the Ethereum side. +Our bridge has contracts on the Ethereum side, and these contracts need to be able to evolve along with the parachain side. Cross-chain governance will control both configuration and code upgrades on the Ethereum side. As a prime example, Polkadot and BEEFY consensus algorithms will change, and so we need to make sure the Ethereum side of the bridge remains compatible. Otherwise locked up collateral will not be redeemable. From d01fc365c59ffe8941995174c79c01e7fe35b97b Mon Sep 17 00:00:00 2001 From: Ron Date: Mon, 3 Jun 2024 17:55:16 +0800 Subject: [PATCH 7/9] Remove lodestar from git modules & Use geth from nix package (#1212) * Remove lodestar from git modules & Use geth from nix package * Cleanup build script * Build source from official repo without fork * Update scripts/init.sh Co-authored-by: Clara van Staden * Fix create the soft link * Add to .gitignore --------- Co-authored-by: Clara van Staden --- .gitignore | 1 + .gitmodules | 4 +-- flake.lock | 24 +++++++++++++-- flake.nix | 1 + lodestar | 1 - scripts/init.sh | 37 +++++++++++------------ web/packages/test/scripts/build-binary.sh | 3 +- 7 files changed, 42 insertions(+), 29 deletions(-) delete mode 160000 lodestar diff --git a/.gitignore b/.gitignore index a7a51e8b67..a4e2d980a3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ go.work* control/target/ web/packages/operations/.env.polkadot web/packages/operations/.env.rococo +lodestar diff --git a/.gitmodules b/.gitmodules index e1c59569a9..b87b18857d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,6 +13,4 @@ [submodule "contracts/lib/prb-math"] path = contracts/lib/prb-math url = https://github.com/PaulRBerg/prb-math -[submodule "lodestar"] - path = lodestar - url = https://github.com/Snowfork/lodestar + diff --git a/flake.lock b/flake.lock index 61b5385a79..ab1c96a30a 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,15 @@ { "nodes": { "flake-utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1676283394, - "narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=", + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "owner": "numtide", "repo": "flake-utils", - "rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "type": "github" }, "original": { @@ -85,6 +88,21 @@ "foundry": "foundry", "nixpkgs": "nixpkgs_2" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 8529150e7b..c44ac3995d 100644 --- a/flake.nix +++ b/flake.nix @@ -86,6 +86,7 @@ export RUSTUP_HOME=$PWD/.rustup export RUST_NIGHTLY_VERSION=nightly-2024-02-08 export PATH=$CARGO_HOME/bin:$PATH + export LODESTAR_VERSION=v1.16.0 eval "$(direnv hook bash)" diff --git a/lodestar b/lodestar deleted file mode 160000 index 5d93a629c0..0000000000 --- a/lodestar +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5d93a629c0fd1fdd32160cbf7717e7a6b22f7f2d diff --git a/scripts/init.sh b/scripts/init.sh index ceff160a9a..71d5bcf198 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -4,18 +4,25 @@ set -eux echo "Checkout polkadot-sdk Snowfork fork" pushd .. -if [ ! -d "polkadot-sdk" ]; then - git clone https://github.com/Snowfork/polkadot-sdk.git -fi -pushd polkadot-sdk -git checkout snowbridge + if [ ! -d "polkadot-sdk" ]; then + git clone https://github.com/Snowfork/polkadot-sdk.git + cd snowbridge && ln -sf ../polkadot-sdk polkadot-sdk + fi + pushd polkadot-sdk + git fetch && git checkout snowbridge + popd popd -popd - -ln -sf ../polkadot-sdk polkadot-sdk -echo "Setting up submodules" -git submodule update --init --recursive || true +echo "Checkout lodestar Snowfork fork" +pushd .. + if [ ! -d "lodestar" ]; then + git clone https://github.com/ChainSafe/lodestar + cd snowbridge && ln -sf ../lodestar lodestar + fi + pushd lodestar + git fetch && git checkout $LODESTAR_VERSION + popd +popd echo "Setting up git hooks" git config --local core.hooksPath hooks/ @@ -36,13 +43,3 @@ cargo install cargo-fuzz echo "Installing web packages" (cd web && pnpm install) -echo "Download geth to replace the nix version" -OS=$(uname -s | tr A-Z a-z) -MACHINE_TYPE=$(uname -m | tr A-Z a-z | sed 's/x86_64/amd64/') - -geth_package=geth-$OS-$MACHINE_TYPE-1.13.11-8f7eb9cc -curl https://gethstore.blob.core.windows.net/builds/$geth_package.tar.gz -o /tmp/geth.tar.gz || { echo 'Download failed'; exit 1; } -mkdir -p $GOPATH/bin -tar -xvf /tmp/geth.tar.gz -C $GOPATH -cp $GOPATH/$geth_package/geth $GOPATH/bin -geth version diff --git a/web/packages/test/scripts/build-binary.sh b/web/packages/test/scripts/build-binary.sh index 74ecfdaa74..8378236040 100755 --- a/web/packages/test/scripts/build-binary.sh +++ b/web/packages/test/scripts/build-binary.sh @@ -17,8 +17,7 @@ build_binaries() { # Check that all 3 binaries are available and no changes made in the polkadot and substrate dirs if [[ ! -e "target/release/polkadot" || ! -e "target/release/polkadot-execute-worker" || ! -e "target/release/polkadot-prepare-worker" || "$changes_detected" -eq 1 ]]; then echo "Building polkadot binary, due to changes detected in polkadot or substrate, or binaries not found" - # Increase session length to 2 mins - ROCOCO_EPOCH_DURATION=20 cargo build --release --locked --bin polkadot --bin polkadot-execute-worker --bin polkadot-prepare-worker + cargo build --release --locked --bin polkadot --bin polkadot-execute-worker --bin polkadot-prepare-worker else echo "No changes detected in polkadot or substrate and binaries are available, not rebuilding relaychain binaries." fi From 4affb81bdbdda1ba4a24975efc87c41694628e2d Mon Sep 17 00:00:00 2001 From: Ron Date: Mon, 3 Jun 2024 18:07:59 +0800 Subject: [PATCH 8/9] Monitoring config for production (#1205) * PagerDuty integration & Clean up * Cleanup to remove dimensions * Add env to name space & finalize config * Suppress logs * Cleanups * Check every 5 mins * Revert "Suppress logs" This reverts commit da224c02c0371b4079c9f4ee50027ef4e57bc719. --- web/packages/api/src/environment.ts | 10 +- web/packages/api/src/status.ts | 9 +- web/packages/operations/.env.example | 2 +- web/packages/operations/src/alarm.ts | 173 +++---------------------- web/packages/operations/src/cron.ts | 2 +- web/packages/operations/src/monitor.ts | 30 ++--- 6 files changed, 44 insertions(+), 182 deletions(-) diff --git a/web/packages/api/src/environment.ts b/web/packages/api/src/environment.ts index 4f3ea4c5d9..f9686035fc 100644 --- a/web/packages/api/src/environment.ts +++ b/web/packages/api/src/environment.ts @@ -283,13 +283,13 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { ], config: { BEACON_HTTP_API: "https://lodestar-mainnet.chainsafe.io", - ETHEREUM_WS_API: (key) => `https://mainnet.infura.io/v3/${key}`, + ETHEREUM_WS_API: (key) => `https://eth-mainnet.g.alchemy.com/v2/${key}`, RELAY_CHAIN_WS_URL: "wss://polkadot-rpc.dwellir.com", ASSET_HUB_WS_URL: "wss://asset-hub-polkadot-rpc.dwellir.com", BRIDGE_HUB_WS_URL: "wss://polkadot-bridge-hub-rpc.dwellir.com", PARACHAINS: [], GATEWAY_CONTRACT: "0x27ca963c279c93801941e1eb8799c23f407d68e7", - BEEFY_CONTRACT: "0x27e5e17ac995d3d720c311e1e9560e28f5855fb1", + BEEFY_CONTRACT: "0xad04888ff41947a2e34f9e7b990bbc6cd85fe1d1", ASSET_HUB_PARAID: 1000, BRIDGE_HUB_PARAID: 1002, PRIMARY_GOVERNANCE_CHANNEL_ID: @@ -299,12 +299,12 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { RELAYERS: [ { name: "beacon", - account: "5FyC9GkHhiAYjMtddwVNc2gx8wBjH9gpMKWbQ1QVXmmJtr8M", + account: "5HHDmTHN4FZYhuMSt3oP8YySDxzPLj9ZGBwxZjSdKf29qcnj", type: "substrate", }, { name: "beefy", - account: "0xF061685F2B729b89a7A5966B3ab9aee15269e8FE", + account: "0xB8124B07467E46dE73eb5c73a7b1E03863F18062", type: "ethereum", }, { @@ -324,7 +324,7 @@ export const SNOWBRIDGE_ENV: { [id: string]: SnowbridgeEnvironment } = { }, { name: "parachain-assethub", - account: "0x0b65d43d159f1C40Bad7768fd59667E3104a2ECE", + account: "0x1F1819C3C68F9533adbB8E51C8E8428a818D693E", type: "ethereum", }, ], diff --git a/web/packages/api/src/status.ts b/web/packages/api/src/status.ts index e7f0c268a5..15169ec142 100644 --- a/web/packages/api/src/status.ts +++ b/web/packages/api/src/status.ts @@ -26,8 +26,15 @@ export type BridgeStatusInfo = { previousEthereumBlockOnPolkadot: number } } + +export enum ChannelKind { + Primary = "Primary", + Secondary = "Secondary", + AssetHub = "AssetHub", +} + export type ChannelStatusInfo = { - name?: string + name?: ChannelKind toEthereum: { outbound: number inbound: number diff --git a/web/packages/operations/.env.example b/web/packages/operations/.env.example index 034cce1751..3e1aaf5753 100644 --- a/web/packages/operations/.env.example +++ b/web/packages/operations/.env.example @@ -1,6 +1,6 @@ NODE_ENV=rococo_sepolia REACT_APP_INFURA_KEY= -SLACK_WEBHOOK_URL= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION=eu-central-1 +SNS_TOPIC_TO_PAGERDUTY=arn:aws:sns:eu-central-1:232374692033:PD diff --git a/web/packages/operations/src/alarm.ts b/web/packages/operations/src/alarm.ts index 74568978f0..0b8af0c85e 100644 --- a/web/packages/operations/src/alarm.ts +++ b/web/packages/operations/src/alarm.ts @@ -1,13 +1,12 @@ import { status, environment } from "@snowbridge/api" -import axios from "axios" import { CloudWatchClient, PutMetricDataCommand, PutMetricAlarmCommand, } from "@aws-sdk/client-cloudwatch" -const SLACK_WEBHOOK_URL = process.env["SLACK_WEBHOOK_URL"] const CLOUD_WATCH_NAME_SPACE = "SnowbridgeMetrics" +const SNS_TOPIC_TO_PAGERDUTY = process.env["SNS_TOPIC_TO_PAGERDUTY"] || "" export const AlarmThreshold = { MaxBlockLatency: 2000, @@ -17,6 +16,7 @@ export const AlarmThreshold = { export type Sovereign = { name: string; account: string; balance: bigint } export type AllMetrics = { + name: string bridgeStatus: status.BridgeStatusInfo channels: status.ChannelStatusInfo[] sovereigns: Sovereign[] @@ -31,50 +31,24 @@ export enum AlarmReason { AccountBalanceInsufficient = "AccountBalanceInsufficient", } -export type ChannelKind = "Primary" | "Secondary" | "AssetHub" - export const sendMetrics = async (metrics: AllMetrics) => { let client = new CloudWatchClient({}) let metricData = [] // Beefy metrics metricData.push({ MetricName: "BeefyLatency", - Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, - ], Value: metrics.bridgeStatus.toEthereum.blockLatency, }) metricData.push({ MetricName: "LatestBeefyBlock", - Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, - ], Value: metrics.bridgeStatus.toEthereum.latestPolkadotBlockOnEthereum, }) metricData.push({ MetricName: "PreviousBeefyBlock", - Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, - ], Value: metrics.bridgeStatus.toEthereum.previousPolkadotBlockOnEthereum, }) metricData.push({ MetricName: AlarmReason.BeefyStale.toString(), - Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, - ], Value: Number( metrics.bridgeStatus.toEthereum.blockLatency > AlarmThreshold.MaxBlockLatency && metrics.bridgeStatus.toEthereum.latestPolkadotBlockOnEthereum <= @@ -84,42 +58,18 @@ export const sendMetrics = async (metrics: AllMetrics) => { // Beacon metrics metricData.push({ MetricName: "BeaconLatency", - Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, - ], Value: metrics.bridgeStatus.toPolkadot.blockLatency, }) metricData.push({ MetricName: "LatestBeaconBlock", - Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, - ], Value: metrics.bridgeStatus.toPolkadot.latestEthereumBlockOnPolkadot, }) metricData.push({ MetricName: "PreviousBeaconBlock", - Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, - ], Value: metrics.bridgeStatus.toPolkadot.previousEthereumBlockOnPolkadot, }) metricData.push({ MetricName: AlarmReason.BeaconStale.toString(), - Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, - ], Value: Number( metrics.bridgeStatus.toPolkadot.blockLatency > AlarmThreshold.MaxBlockLatency && metrics.bridgeStatus.toPolkadot.latestEthereumBlockOnPolkadot <= @@ -132,10 +82,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { metricData.push({ MetricName: "ToEthereumOutboundNonce", Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, { Name: "ChannelName", Value: channel.name, @@ -146,10 +92,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { metricData.push({ MetricName: "ToEthereumPreviousOutboundNonce", Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, { Name: "ChannelName", Value: channel.name, @@ -160,10 +102,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { metricData.push({ MetricName: "ToEthereumInboundNonce", Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, { Name: "ChannelName", Value: channel.name, @@ -174,10 +112,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { metricData.push({ MetricName: "ToEthereumPreviousInboundNonce", Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, { Name: "ChannelName", Value: channel.name, @@ -187,12 +121,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { }) metricData.push({ MetricName: AlarmReason.ToEthereumChannelStale.toString(), - Dimensions: [ - { - Name: "Direction", - Value: "ToEthereum", - }, - ], Value: Number( channel.toEthereum.outbound < channel.toEthereum.inbound || (channel.toEthereum.outbound > channel.toEthereum.inbound && @@ -203,10 +131,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { metricData.push({ MetricName: "ToPolkadotOutboundNonce", Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, { Name: "ChannelName", Value: channel.name, @@ -217,10 +141,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { metricData.push({ MetricName: "ToPolkadotPreviousOutboundNonce", Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, { Name: "ChannelName", Value: channel.name, @@ -231,10 +151,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { metricData.push({ MetricName: "ToPolkadotInboundNonce", Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, { Name: "ChannelName", Value: channel.name, @@ -245,10 +161,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { metricData.push({ MetricName: "ToPolkadotPreviousInboundNonce", Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, { Name: "ChannelName", Value: channel.name, @@ -258,12 +170,6 @@ export const sendMetrics = async (metrics: AllMetrics) => { }) metricData.push({ MetricName: AlarmReason.ToPolkadotChannelStale.toString(), - Dimensions: [ - { - Name: "Direction", - Value: "ToPolkadot", - }, - ], Value: Number( channel.toPolkadot.outbound < channel.toPolkadot.inbound || (channel.toPolkadot.outbound > channel.toPolkadot.inbound && @@ -307,19 +213,30 @@ export const sendMetrics = async (metrics: AllMetrics) => { } const command = new PutMetricDataCommand({ MetricData: metricData, - Namespace: CLOUD_WATCH_NAME_SPACE, + Namespace: CLOUD_WATCH_NAME_SPACE + "-" + metrics.name, }) await client.send(command) } export const initializeAlarms = async () => { + let env = "local_e2e" + if (process.env.NODE_ENV !== undefined) { + env = process.env.NODE_ENV + } + const snowbridgeEnv = environment.SNOWBRIDGE_ENV[env] + if (snowbridgeEnv === undefined) { + throw Error(`Unknown environment '${env}'`) + } + const { name } = snowbridgeEnv + let client = new CloudWatchClient({}) let cloudWatchAlarms = [] let alarmCommandSharedInput = { EvaluationPeriods: 3, - Namespace: CLOUD_WATCH_NAME_SPACE, - Period: 600, + Namespace: CLOUD_WATCH_NAME_SPACE + "-" + name, + Period: 300, Threshold: 0, + AlarmActions: [SNS_TOPIC_TO_PAGERDUTY], } cloudWatchAlarms.push( new PutMetricAlarmCommand({ @@ -371,65 +288,7 @@ export const initializeAlarms = async () => { ...alarmCommandSharedInput, }) ) - console.log(cloudWatchAlarms) for (let alarm of cloudWatchAlarms) { await client.send(alarm) } } - -export const sendAlarm = async (metrics: AllMetrics) => { - let alarm = false - let alarms = [] - - if ( - metrics.bridgeStatus.toEthereum.blockLatency > AlarmThreshold.MaxBlockLatency && - metrics.bridgeStatus.toEthereum.latestPolkadotBlockOnEthereum == - metrics.bridgeStatus.toEthereum.previousPolkadotBlockOnEthereum - ) { - alarm = true - alarms.push(AlarmReason.BeefyStale) - } - if ( - metrics.bridgeStatus.toPolkadot.blockLatency > AlarmThreshold.MaxBlockLatency && - metrics.bridgeStatus.toPolkadot.latestEthereumBlockOnPolkadot == - metrics.bridgeStatus.toPolkadot.previousEthereumBlockOnPolkadot - ) { - alarm = true - alarms.push(AlarmReason.BeaconStale) - } - for (let channel of metrics.channels) { - if ( - channel.toEthereum.outbound != channel.toEthereum.inbound && - channel.toEthereum.inbound == channel.toEthereum.previousInbound - ) { - alarm = true - alarms.push(AlarmReason.ToEthereumChannelStale) - } - if ( - channel.toPolkadot.outbound != channel.toPolkadot.inbound && - channel.toPolkadot.inbound == channel.toPolkadot.previousInbound - ) { - alarm = true - alarms.push(AlarmReason.ToPolkadotChannelStale) - } - break - } - - for (let relayer of metrics.relayers) { - if (!relayer.balance || relayer.balance < AlarmThreshold.MinBalanceToKeep) { - alarm = true - alarms.push(AlarmReason.AccountBalanceInsufficient) - break - } - } - const text = JSON.stringify( - { alarms, metrics }, - (key, value) => (typeof value === "bigint" ? value.toString() : value), - 2 - ) - console.log(text) - - if (alarm) { - await axios.post(SLACK_WEBHOOK_URL || "", { text }) - } -} diff --git a/web/packages/operations/src/cron.ts b/web/packages/operations/src/cron.ts index f819477afd..7fd3696d6a 100644 --- a/web/packages/operations/src/cron.ts +++ b/web/packages/operations/src/cron.ts @@ -2,4 +2,4 @@ import "dotenv/config" import cron from "node-cron" import { monitor } from "./monitor" -cron.schedule("*/10 * * * *", monitor) +cron.schedule("*/5 * * * *", monitor) diff --git a/web/packages/operations/src/monitor.ts b/web/packages/operations/src/monitor.ts index b5384c9416..f18fd7d525 100644 --- a/web/packages/operations/src/monitor.ts +++ b/web/packages/operations/src/monitor.ts @@ -1,19 +1,19 @@ import { u8aToHex } from "@polkadot/util" import { blake2AsU8a } from "@polkadot/util-crypto" import { contextFactory, destroyContext, environment, status, utils } from "@snowbridge/api" -import { sendAlarm, AllMetrics, Sovereign, sendMetrics } from "./alarm" +import { AllMetrics, Sovereign, sendMetrics } from "./alarm" export const monitor = async (): Promise => { let env = "local_e2e" if (process.env.NODE_ENV !== undefined) { env = process.env.NODE_ENV } - const snwobridgeEnv = environment.SNOWBRIDGE_ENV[env] - if (snwobridgeEnv === undefined) { + const snowbridgeEnv = environment.SNOWBRIDGE_ENV[env] + if (snowbridgeEnv === undefined) { throw Error(`Unknown environment '${env}'`) } - const { config } = snwobridgeEnv + const { config, name } = snowbridgeEnv const infuraKey = process.env.REACT_APP_INFURA_KEY || "" @@ -35,22 +35,25 @@ export const monitor = async (): Promise => { }, }) - const bridegStatus = await status.bridgeStatusInfo(context) - console.log("Bridge Status:", bridegStatus) + const bridgeStatus = await status.bridgeStatusInfo(context) + console.log("Bridge Status:", bridgeStatus) + const assethub = await status.channelStatusInfo( context, utils.paraIdToChannelId(config.ASSET_HUB_PARAID) ) - assethub.name = "AssetHub" + assethub.name = status.ChannelKind.AssetHub console.log("Asset Hub Channel:", assethub) + const primaryGov = await status.channelStatusInfo(context, config.PRIMARY_GOVERNANCE_CHANNEL_ID) - primaryGov.name = "Primary" + primaryGov.name = status.ChannelKind.Primary console.log("Primary Governance Channel:", primaryGov) + const secondaryGov = await status.channelStatusInfo( context, config.SECONDARY_GOVERNANCE_CHANNEL_ID ) - secondaryGov.name = "Secondary" + secondaryGov.name = status.ChannelKind.Secondary console.log("Secondary Governance Channel:", secondaryGov) let assetHubSovereign = BigInt( @@ -125,17 +128,10 @@ export const monitor = async (): Promise => { }, ] - const allMetrics: AllMetrics = { - bridgeStatus: bridegStatus, - channels: channels, - relayers: relayers, - sovereigns, - } + const allMetrics: AllMetrics = { name, bridgeStatus, channels, relayers, sovereigns } await sendMetrics(allMetrics) - await sendAlarm(allMetrics) - await destroyContext(context) return allMetrics From 6d26d047d7964b1b9570bcf1f329b7d96b63e82a Mon Sep 17 00:00:00 2001 From: Vincent Geddes <117534+vgeddes@users.noreply.github.com> Date: Mon, 3 Jun 2024 16:12:39 +0200 Subject: [PATCH 9/9] unused context parameter --- relayer/relays/beefy/main.go | 2 +- relayer/relays/beefy/polkadot-listener.go | 4 ++-- relayer/relays/beefy/scanner.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/relayer/relays/beefy/main.go b/relayer/relays/beefy/main.go index 1222f88a76..209e33e5d8 100644 --- a/relayer/relays/beefy/main.go +++ b/relayer/relays/beefy/main.go @@ -112,7 +112,7 @@ func (relay *Relay) OneShotSync(ctx context.Context, blockNumber uint64) error { } // generate beefy update for that specific relay block - task, err := relay.polkadotListener.generateBeefyUpdate(ctx, blockNumber) + task, err := relay.polkadotListener.generateBeefyUpdate(blockNumber) if err != nil { return fmt.Errorf("fail to generate next beefy request: %w", err) } diff --git a/relayer/relays/beefy/polkadot-listener.go b/relayer/relays/beefy/polkadot-listener.go index 9c55caf847..24894af1e8 100644 --- a/relayer/relays/beefy/polkadot-listener.go +++ b/relayer/relays/beefy/polkadot-listener.go @@ -121,7 +121,7 @@ func (li *PolkadotListener) queryBeefyAuthorities(blockHash types.Hash) ([]subst return authorities, nil } -func (li *PolkadotListener) generateBeefyUpdate(ctx context.Context, relayBlockNumber uint64) (Request, error) { +func (li *PolkadotListener) generateBeefyUpdate(relayBlockNumber uint64) (Request, error) { api := li.conn.API() meta := li.conn.Metadata() var request Request @@ -130,7 +130,7 @@ func (li *PolkadotListener) generateBeefyUpdate(ctx context.Context, relayBlockN return request, fmt.Errorf("find match beefy block: %w", err) } - commitment, proof, err := fetchCommitmentAndProof(ctx, meta, api, beefyBlockHash) + commitment, proof, err := fetchCommitmentAndProof(meta, api, beefyBlockHash) if err != nil { return request, fmt.Errorf("fetch commitment and proof: %w", err) } diff --git a/relayer/relays/beefy/scanner.go b/relayer/relays/beefy/scanner.go index 6c84cc06bb..d6f08352f5 100644 --- a/relayer/relays/beefy/scanner.go +++ b/relayer/relays/beefy/scanner.go @@ -169,7 +169,7 @@ func scanCommitments(ctx context.Context, meta *types.Metadata, api *gsrpc.Subst return } - commitment, proof, err := fetchCommitmentAndProof(ctx, meta, api, result.BlockHash) + commitment, proof, err := fetchCommitmentAndProof(meta, api, result.BlockHash) if err != nil { emitError(fmt.Errorf("fetch commitment and proof: %w", err)) return @@ -240,7 +240,7 @@ func verifyProof(meta *types.Metadata, api *gsrpc.SubstrateAPI, proof merkle.Sim return actualRoot == expectedRoot, nil } -func fetchCommitmentAndProof(ctx context.Context, meta *types.Metadata, api *gsrpc.SubstrateAPI, beefyBlockHash types.Hash) (*types.SignedCommitment, *merkle.SimplifiedMMRProof, error) { +func fetchCommitmentAndProof(meta *types.Metadata, api *gsrpc.SubstrateAPI, beefyBlockHash types.Hash) (*types.SignedCommitment, *merkle.SimplifiedMMRProof, error) { beefyHeader, err := api.RPC.Chain.GetHeader(beefyBlockHash) if err != nil { return nil, nil, fmt.Errorf("fetch header: %w", err)