diff --git a/README.md b/README.md index 66b3cc82..c1f1db46 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A Web3 RPC compatible layer build upon Godwoken/Polyjuice. +Checkout [additional feature](docs/addtional-feature.md). + ## Development ### Config database @@ -121,9 +123,26 @@ docker exec -it /bin/bash $ root@ec562fe2172b:/godwoken-web3# pm2 monit ``` -Http url: http://your-url/ +#### URLs + +```sh +# Http +http://example_web3_rpc_url + +# WebSocket +ws://example_web3_rpc_url/ws +``` -WebSocket url: ws://your-url/ws +With instant-finality feature turn on: + +```sh +# Http +http://example_web3_rpc_url?instant-finality-hack=true +http://example_web3_rpc_url/instant-finality-hack + +# WebSocket +ws://example_web3_rpc_url/ws?instant-finality-hack=true +``` ### Docker Prebuilds diff --git a/docs/addtional-feature.md b/docs/addtional-feature.md new file mode 100644 index 00000000..e274c553 --- /dev/null +++ b/docs/addtional-feature.md @@ -0,0 +1,22 @@ +# Additional Feature + +## Instant Finality + +Ethereum require a transaction to be on-chain(meaning the transaction is included in the latest block) before returning a final status(aka. transaction receipt) to users so they can know whether the transaction is success or not. + +Godwoken provide a quicker way to confirm transaction. Once the transaction is validated in mempool, users can get instant transaction receipt. This feature is called Instant Finality. + +If your want to build a low latency user experience for on-chain interaction in your dapp, you can turn on such feature by using the RPC with additional path or query parameter: + +```sh +# http +https://example_web3_rpc_url?instant-finality-hack=true +https://example_web3_rpc_url/instant-finality-hack + +# websocket +ws://example_web3_rpc_url/ws?instant-finality-hack=true +``` + +Environment like [Hardhat](https://github.com/NomicFoundation/hardhat) will swallow the http url's query parameter, so you might want to use the `/instant-finality-hack` path to overcome that. + +Also notice that under such mode, there might have some [compatibility issue](https://github.com/godwokenrises/godwoken-web3/issues/283) with Ethereum toolchain like `ether.js`. If you care more about the compatibility, please use the bare RPC url `https://example_web3_rpc_url`, which is consider to be most compatible with Ethereum. diff --git a/packages/api-server/src/methods/index.ts b/packages/api-server/src/methods/index.ts index 5424907e..484a458e 100644 --- a/packages/api-server/src/methods/index.ts +++ b/packages/api-server/src/methods/index.ts @@ -75,4 +75,9 @@ function getMethods(argsList: ModConstructorArgs = {}) { return methods; } +const instantFinalityHackMode = true; + export const methods = getMethods(); +export const instantFinalityHackMethods = getMethods({ + eth: [instantFinalityHackMode], +}); diff --git a/packages/api-server/src/methods/modules/eth.ts b/packages/api-server/src/methods/modules/eth.ts index 6e4cdd1e..72b832e9 100644 --- a/packages/api-server/src/methods/modules/eth.ts +++ b/packages/api-server/src/methods/modules/eth.ts @@ -94,16 +94,18 @@ type GodwokenBlockParameter = U64 | undefined; export class Eth { private query: Query; private rpc: GodwokenClient; + private instantFinalityHackMode: boolean; private filterManager: FilterManager; private cacheStore: Store; private ethNormalizer: EthNormalizer; - constructor() { + constructor(instantFinalityHackMode: boolean = false) { this.query = new Query(); this.rpc = new GodwokenClient( envConfig.godwokenJsonRpc, envConfig.godwokenReadonlyJsonRpc ); + this.instantFinalityHackMode = instantFinalityHackMode; this.filterManager = new FilterManager(true); this.cacheStore = new Store(true, CACHE_EXPIRED_TIME_MILSECS); this.ethNormalizer = new EthNormalizer(this.rpc); @@ -718,19 +720,19 @@ export class Eth { async getTransactionByHash(args: [string]): Promise { const ethTxHash: Hash = args[0]; - const cacheKey = autoCreateAccountCacheKey(ethTxHash); + const acaCacheKey = autoCreateAccountCacheKey(ethTxHash); // 1. Find in db const tx = await this.query.getTransactionByEthTxHash(ethTxHash); if (tx != null) { // no need await // delete auto create account tx if already in db - this.cacheStore.delete(cacheKey); + this.cacheStore.delete(acaCacheKey); const apiTx = toApiTransaction(tx); return apiTx; } - // 2. If null, find pending transactions + // 2. Find pending tx from gw mempool block const ethTxHashKey = ethTxHashCacheKey(ethTxHash); const gwTxHash: Hash | null = await this.cacheStore.get(ethTxHashKey); if (gwTxHash != null) { @@ -768,7 +770,7 @@ export class Eth { // 3. Find by auto create account tx // TODO: delete cache store if dropped by godwoken // convert to tx hash mapping store if account id generated ? - const polyjuiceRawTx = await this.cacheStore.get(cacheKey); + const polyjuiceRawTx = await this.cacheStore.get(acaCacheKey); if (polyjuiceRawTx != null) { const tipBlock = await this.query.getTipBlock(); if (tipBlock == null) { @@ -797,7 +799,7 @@ export class Eth { return apiTransaction; } else { // If not found, means dropped by godwoken, should delete cache - this.cacheStore.delete(cacheKey); + this.cacheStore.delete(acaCacheKey); } } @@ -861,6 +863,7 @@ export class Eth { return null; } + // 1. Find in db const data = await this.query.getTransactionAndLogsByHash(gwTxHash); if (data != null) { const [tx, logs] = data; @@ -869,37 +872,43 @@ export class Eth { return transactionReceipt; } - const godwokenTxWithStatus = await this.rpc.getTransaction(gwTxHash); - if (godwokenTxWithStatus == null) { - return null; - } - const godwokenTxReceipt = await this.rpc.getTransactionReceipt(gwTxHash); - if (godwokenTxReceipt == null) { - return null; - } - const tipBlock = await this.query.getTipBlock(); - if (tipBlock == null) { - throw new Error(`tip block not found`); - } - let ethTxInfo = undefined; - try { - ethTxInfo = await filterWeb3Transaction( - ethTxHash, - this.rpc, - tipBlock.number, - tipBlock.hash, - godwokenTxWithStatus.transaction, - godwokenTxReceipt + // 2. If under instant-finality hack mode, build receipt from gw mempool block + if (this.instantFinalityHackMode) { + logger.debug( + `[eth_getTransactionReceipt] find with instant-finality hack` ); - } catch (err) { - logger.error("filterWeb3Transaction:", err); - logger.info("godwoken tx:", godwokenTxWithStatus); - logger.info("godwoken receipt:", godwokenTxReceipt); - throw err; - } - if (ethTxInfo != null) { - const ethTxReceipt = ethTxInfo[1]!; - return ethTxReceipt; + const godwokenTxWithStatus = await this.rpc.getTransaction(gwTxHash); + if (godwokenTxWithStatus == null) { + return null; + } + const godwokenTxReceipt = await this.rpc.getTransactionReceipt(gwTxHash); + if (godwokenTxReceipt == null) { + return null; + } + const tipBlock = await this.query.getTipBlock(); + if (tipBlock == null) { + throw new Error(`tip block not found`); + } + let ethTxInfo = undefined; + try { + ethTxInfo = await filterWeb3Transaction( + ethTxHash, + this.rpc, + tipBlock.number, + tipBlock.hash, + godwokenTxWithStatus.transaction, + godwokenTxReceipt + ); + } catch (err) { + logger.error("filterWeb3Transaction:", err); + logger.info("godwoken tx:", godwokenTxWithStatus); + logger.info("godwoken receipt:", godwokenTxReceipt); + throw err; + } + if (ethTxInfo != null) { + const ethTxReceipt = ethTxInfo[1]!; + return ethTxReceipt; + } } return null; @@ -1099,7 +1108,11 @@ export class Eth { ): Promise { switch (blockParameter) { case "latest": - return undefined; + if (this.instantFinalityHackMode) { + // under instant-finality hack, we treat latest as pending + return undefined; + } + return await this.getTipNumber(); case "earliest": return 0n; // It's supposed to be filtered in the validator, so throw an error if matched @@ -1143,6 +1156,9 @@ export class Eth { return blockNumber; } + // Some RPCs does not support pending parameter + // eth_getBlockByNumber/eth_getBlockTransactionCountByNumber/eth_getTransactionByBlockNumberAndIndex + // TODO: maybe we should support for those as well? private async blockParameterToBlockNumber( blockParameter: BlockParameter ): Promise { @@ -1468,7 +1484,7 @@ function serializeEthCallParameters( gasPrice: ethCallObj.gasPrice || "0x", data: ethCallObj.data || "0x", value: ethCallObj.value || "0x", - blockNumber: blockNumber ? "0x" + blockNumber?.toString(16) : "0x", // undefined means latest block, the key contains tipBlockHash, so there is no need to diff latest height + blockNumber: blockNumber ? "0x" + blockNumber?.toString(16) : "0x", // undefined means pending block, the key contains tipBlockHash, so there is no need to diff pending height }; return JSON.stringify(toSerializeObj); } @@ -1498,7 +1514,7 @@ function serializeEstimateGasParameters( gasPrice: estimateGasObj.gasPrice || "0x", data: estimateGasObj.data || "0x", value: estimateGasObj.value || "0x", - blockNumber: blockNumber ? "0x" + blockNumber?.toString(16) : "0x", // undefined means latest block, the key contains tipBlockHash, so there is no need to diff latest height + blockNumber: blockNumber ? "0x" + blockNumber?.toString(16) : "0x", // undefined means pending block, the key contains tipBlockHash, so there is no need to diff pending height }; return JSON.stringify(toSerializeObj); } diff --git a/packages/api-server/src/middlewares/jayson.ts b/packages/api-server/src/middlewares/jayson.ts index ea0d28e9..0110679e 100644 --- a/packages/api-server/src/middlewares/jayson.ts +++ b/packages/api-server/src/middlewares/jayson.ts @@ -1,9 +1,11 @@ import jayson from "jayson"; -import { methods } from "../methods/index"; +import { instantFinalityHackMethods, methods } from "../methods/index"; import { Request, Response, NextFunction } from "express"; import createServer from "connect"; +import { isInstantFinalityHackMode } from "../util"; const server = new jayson.Server(methods); +const instantFinalityHackServer = new jayson.Server(instantFinalityHackMethods); export const jaysonMiddleware = ( req: Request, @@ -16,6 +18,13 @@ export const jaysonMiddleware = ( req.body.params = [] as any[]; } + // enable additional feature for special URL + if (isInstantFinalityHackMode(req)) { + const middleware = + instantFinalityHackServer.middleware() as createServer.NextHandleFunction; + return middleware(req, res, next); + } + const middleware = server.middleware() as createServer.NextHandleFunction; return middleware(req, res, next); }; diff --git a/packages/api-server/src/util.ts b/packages/api-server/src/util.ts index fe95f5a4..35cf1e71 100644 --- a/packages/api-server/src/util.ts +++ b/packages/api-server/src/util.ts @@ -1,4 +1,5 @@ import { HexString } from "@ckb-lumos/base"; +import { Request } from "express"; import { TX_DATA_NONE_ZERO_GAS, TX_DATA_ZERO_GAS, @@ -121,6 +122,14 @@ export function calcFee(serializedL2Tx: HexString, feeRate: bigint) { return byteLen * feeRate; } +// WEB3_RPC_URL/instant-finality-hack or WEB3_RPC_URL?instant-finality-hack=true +export function isInstantFinalityHackMode(req: Request): boolean { + return ( + req.url == "/instant-finality-hack" || + (req.query && req.query["instant-finality-hack"] == "true") + ); +} + export async function asyncSleep(ms = 0) { return new Promise((r) => setTimeout(() => r("ok"), ms)); } diff --git a/packages/api-server/src/ws/methods.ts b/packages/api-server/src/ws/methods.ts index 35092d32..b491f8a4 100644 --- a/packages/api-server/src/ws/methods.ts +++ b/packages/api-server/src/ws/methods.ts @@ -1,7 +1,10 @@ import { EthNewHead } from "../base/types/api"; import { BlockEmitter } from "../block-emitter"; import { INVALID_PARAMS, METHOD_NOT_FOUND } from "../methods/error-code"; -import { methods } from "../methods/index"; +import { + instantFinalityHackMethods, + methods as compatibleMethods, +} from "../methods/index"; import { middleware as wsrpc } from "./wss"; import crypto from "crypto"; import { HexNumber } from "@ckb-lumos/base"; @@ -11,6 +14,7 @@ import { Store } from "../cache/store"; import { CACHE_EXPIRED_TIME_MILSECS } from "../cache/constant"; import { wsApplyRateLimitByIp } from "../rate-limit"; import { gwTxHashToEthTxHash } from "../cache/tx-hash"; +import { isInstantFinalityHackMode } from "../util"; const query = new Query(); const cacheStore = new Store(true, CACHE_EXPIRED_TIME_MILSECS); @@ -25,6 +29,13 @@ export function wrapper(ws: any, req: any) { wsrpc(ws); + // check if use most compatible or enable additional feature + let methods = compatibleMethods; + if (isInstantFinalityHackMode(req)) { + methods = instantFinalityHackMethods; + } + + // 1. RPC request for (const [method, methodFunc] of Object.entries(methods)) { ws.on(method, async function (...args: any[]) { const execMethod = async () => { @@ -54,6 +65,48 @@ export function wrapper(ws: any, req: any) { }); } + // 2. RPC batch request + ws.on("@batchRequests", async function (...args: any[]) { + const objs = args.slice(0, args.length - 1); + const cb = args[args.length - 1]; + + const callback = (err: any, result: any) => { + return { err, result }; + }; + const info = await Promise.all( + objs.map(async (obj) => { + // check rate limit + const err = await wsApplyRateLimitByIp(req, obj.method); + if (err != null) { + return { + err, + }; + } + + if (obj.method === "eth_subscribe") { + const r = ethSubscribe(obj.params, callback); + return r; + } else if (obj.method === "eth_unsubscribe") { + const r = ethUnsubscribe(obj.params, callback); + return r; + } + const value = methods[obj.method]; + if (value == null) { + return { + err: { + code: METHOD_NOT_FOUND, + message: `method ${obj.method} not found!`, + }, + }; + } + const r = await (value as any)(obj.params, callback); + return r; + }) + ); + cb(info); + }); + + // 3. RPC Subscribe request const newHeadsIds: Set = new Set(); const syncingIds: Set = new Set(); const logsQueryMaps: Map = new Map(); @@ -203,44 +256,4 @@ export function wrapper(ws: any, req: any) { return {}; } - - ws.on("@batchRequests", async function (...args: any[]) { - const objs = args.slice(0, args.length - 1); - const cb = args[args.length - 1]; - - const callback = (err: any, result: any) => { - return { err, result }; - }; - const info = await Promise.all( - objs.map(async (obj) => { - // check rate limit - const err = await wsApplyRateLimitByIp(req, obj.method); - if (err != null) { - return { - err, - }; - } - - if (obj.method === "eth_subscribe") { - const r = ethSubscribe(obj.params, callback); - return r; - } else if (obj.method === "eth_unsubscribe") { - const r = ethUnsubscribe(obj.params, callback); - return r; - } - const value = methods[obj.method]; - if (value == null) { - return { - err: { - code: METHOD_NOT_FOUND, - message: `method ${obj.method} not found!`, - }, - }; - } - const r = await (value as any)(obj.params, callback); - return r; - }) - ); - cb(info); - }); }