nim-web3/web3.nim

685 lines
23 KiB
Nim

import
std/[macros, strutils, options, math, json, tables, uri, strformat]
from os import DirSep, AltSep
import
stint, httputils, chronicles, chronos, nimcrypto/keccak,
json_rpc/[rpcclient, jsonmarshal], stew/byteutils, eth/keys,
web3/[ethtypes, conversions, ethhexstrings, transaction_signing, encoding]
template sourceDir: string = currentSourcePath.rsplit({DirSep, AltSep}, 1)[0]
## Generate client convenience marshalling wrappers from forward declarations
createRpcSigs(RpcClient, sourceDir & "/web3/ethcallsigs.nim")
export UInt256, Int256, Uint128, Int128
export ethtypes, conversions, encoding
type
Web3* = ref object
provider*: RpcClient
subscriptions*: Table[string, Subscription]
defaultAccount*: Address
privateKey*: Option[PrivateKey]
lastKnownNonce*: Option[Nonce]
onDisconnect*: proc() {.gcsafe, raises: [Defect].}
Sender*[T] = ref object
web3*: Web3
contractAddress*: Address
EncodeResult* = tuple[dynamic: bool, data: string]
SubscriptionEventHandler* = proc (j: JsonNode) {.gcsafe, raises: [Defect].}
SubscriptionErrorHandler* = proc (err: CatchableError) {.gcsafe, raises: [Defect].}
BlockHeaderHandler* = proc (b: BlockHeader) {.gcsafe, raises: [Defect].}
Subscription* = ref object
id*: string
web3*: Web3
eventHandler*: SubscriptionEventHandler
errorHandler*: SubscriptionErrorHandler
pendingEvents: seq[JsonNode]
historicalEventsProcessed: bool
removed: bool
ContractCallBase = ref object of RootObj
web3: Web3
data: string
to: Address
value: UInt256
ContractCall*[T] = ref object of ContractCallBase
proc handleSubscriptionNotification(w: Web3, j: JsonNode) =
let s = w.subscriptions.getOrDefault(j{"subscription"}.getStr())
if not s.isNil and not s.removed:
if s.historicalEventsProcessed:
s.eventHandler(j{"result"})
else:
s.pendingEvents.add(j)
proc newWeb3*(provider: RpcClient): Web3 =
result = Web3(provider: provider)
result.subscriptions = initTable[string, Subscription]()
let r = result
provider.setMethodHandler("eth_subscription") do(j: JsonNode):
r.handleSubscriptionNotification(j)
proc newWeb3*(
uri: string, getHeaders: GetJsonRpcRequestHeaders = nil):
Future[Web3] {.async.} =
let u = parseUri(uri)
var provider: RpcClient
case u.scheme
of "http", "https":
let p = newRpcHttpClient(getHeaders = getHeaders)
await p.connect(uri)
provider = p
of "ws", "wss":
let p = newRpcWebSocketClient(getHeaders = getHeaders)
await p.connect(uri)
provider = p
else:
raise newException(CatchableError, "Unknown web3 url scheme")
result = newWeb3(provider)
let r = result
provider.onDisconnect = proc() =
r.subscriptions.clear()
if not r.onDisconnect.isNil:
r.onDisconnect()
proc close*(web3: Web3): Future[void] = web3.provider.close()
proc getHistoricalEvents(s: Subscription, options: JsonNode) {.async.} =
try:
let logs = await s.web3.provider.eth_getLogs(options)
for l in logs:
if s.removed: break
s.eventHandler(l)
s.historicalEventsProcessed = true
var i = 0
while i < s.pendingEvents.len: # Mind reentrancy
if s.removed: break
s.eventHandler(s.pendingEvents[i])
inc i
s.pendingEvents = @[]
except CatchableError as e:
echo "Caught exception in getHistoricalEvents: ", e.msg
echo e.getStackTrace()
proc subscribe*(w: Web3, name: string, options: JsonNode,
eventHandler: SubscriptionEventHandler,
errorHandler: SubscriptionErrorHandler): Future[Subscription]
{.async.} =
## Sets up a new subsciption using the `eth_subscribe` RPC call.
##
## May raise a `CatchableError` if the subscription is not established.
##
## Once the subscription is established, the `eventHandler` callback
## will be executed for each event of interest.
##
## In case of any errors or illegal behavior of the remote RPC node,
## the `errorHandler` will be executed with relevant information about
## the error.
# Don't send an empty `{}` object as an extra argument if there are no options
let id = if options.isNil:
await w.provider.eth_subscribe(name)
else:
await w.provider.eth_subscribe(name, options)
result = Subscription(id: id,
web3: w,
eventHandler: eventHandler,
errorHandler: errorHandler)
w.subscriptions[id] = result
proc subscribeForLogs*(w: Web3, options: JsonNode,
logsHandler: SubscriptionEventHandler,
errorHandler: SubscriptionErrorHandler,
withHistoricEvents = true): Future[Subscription]
{.async.} =
result = await subscribe(w, "logs", options, logsHandler, errorHandler)
if withHistoricEvents:
discard getHistoricalEvents(result, options)
else:
result.historicalEventsProcessed = true
proc subscribeForBlockHeaders*(w: Web3,
blockHeadersCallback: proc(b: BlockHeader) {.gcsafe, raises: [Defect].},
errorHandler: SubscriptionErrorHandler): Future[Subscription]
{.async.} =
proc eventHandler(json: JsonNode) {.gcsafe, raises: [Defect].} =
var blk: BlockHeader
try:
fromJson(json, "result", blk)
blockHeadersCallback(blk)
except CatchableError as err:
errorHandler(err[])
# `nil` options so that we skip sending an empty `{}` object as an extra argument
# to geth for `newHeads`: https://github.com/ethereum/go-ethereum/issues/21588
result = await subscribe(w, "newHeads", nil, eventHandler, errorHandler)
result.historicalEventsProcessed = true
proc unsubscribe*(s: Subscription): Future[void] {.async.} =
s.web3.subscriptions.del(s.id)
s.removed = true
discard await s.web3.provider.eth_unsubscribe(s.id)
proc unknownType() = discard # Used for informative errors
template typeSignature(T: typedesc): string =
when T is string:
"string"
elif T is DynamicBytes:
"bytes"
elif T is FixedBytes:
"bytes" & $T.N
elif T is StUint:
"uint" & $T.bits
elif T is Address:
"address"
elif T is Bool:
"bool"
else:
unknownType(T)
proc initContractCall[T](web3: Web3, data: string, to: Address): ContractCall[T] {.inline.} =
ContractCall[T](web3: web3, data: data, to: to)
type
InterfaceObjectKind = enum
function, constructor, event
MutabilityKind = enum
pure, view, nonpayable, payable
FunctionInputOutput = object
name: string
typ: NimNode
EventInput = object
name: string
typ: NimNode
indexed: bool
FunctionObject = object
name: string
stateMutability: MutabilityKind
inputs: seq[FunctionInputOutput]
outputs: seq[FunctionInputOutput]
ConstructorObject = object
stateMutability: MutabilityKind
inputs: seq[FunctionInputOutput]
outputs: seq[FunctionInputOutput]
EventObject = object
name: string
inputs: seq[EventInput]
anonymous: bool
InterfaceObject = object
case kind: InterfaceObjectKind
of function: functionObject: FunctionObject
of constructor: constructorObject: ConstructorObject
of event: eventObject: EventObject
proc joinStrings(s: varargs[string]): string = join(s)
proc getSignature(function: FunctionObject | EventObject): NimNode =
result = newCall(bindSym"joinStrings")
result.add(newLit(function.name & "("))
for i, input in function.inputs:
result.add(newCall(bindSym"typeSignature", input.typ))
if i != function.inputs.high:
result.add(newLit(","))
result.add(newLit(")"))
result = newCall(ident"static", result)
proc addAddressAndSignatureToOptions(options: JsonNode, address: Address, signature: string): JsonNode =
result = options
if result.isNil:
result = newJObject()
if "address" notin result:
result["address"] = %address
var topics = result{"topics"}
if topics.isNil:
topics = newJArray()
result["topics"] = topics
topics.elems.insert(%signature, 0)
proc parseContract(body: NimNode): seq[InterfaceObject] =
proc parseOutputs(outputNode: NimNode): seq[FunctionInputOutput] =
result.add FunctionInputOutput(typ: (if outputNode.kind == nnkEmpty: ident"void" else: outputNode))
proc parseInputs(inputNodes: NimNode): seq[FunctionInputOutput] =
for i in 1..<inputNodes.len:
let input = inputNodes[i]
input.expectKind(nnkIdentDefs)
let typ = input[^2]
for j in 0 .. input.len - 3:
let arg = input[j]
result.add(FunctionInputOutput(
name: $arg,
typ: typ,
))
proc parseEventInputs(inputNodes: NimNode): seq[EventInput] =
for i in 1..<inputNodes.len:
let input = inputNodes[i]
input.expectKind(nnkIdentDefs)
let typ = input[^2]
for j in 0 .. input.len - 3:
let arg = input[j]
case typ.kind:
of nnkBracketExpr:
if $typ[0] == "indexed":
result.add EventInput(
name: $arg,
typ: typ[1],
indexed: true
)
else:
result.add EventInput(name: $arg, typ: typ)
else:
result.add EventInput(name: $arg, typ: typ)
var
constructor: Option[ConstructorObject]
functions: seq[FunctionObject]
events: seq[EventObject]
for procdef in body:
doAssert(procdef.kind == nnkProcDef,
"Contracts can only be built with procedures")
let
isconstructor = procdef[4].findChild(it.strVal == "constructor") != nil
isevent = procdef[4].findChild(it.strVal == "event") != nil
doAssert(not (isconstructor and constructor.isSome),
"Contract can only have a single constructor")
doAssert(not (isconstructor and isevent),
"Can't be both event and constructor")
if not isevent:
let
ispure = procdef[4].findChild(it.strVal == "pure") != nil
isview = procdef[4].findChild(it.strVal == "view") != nil
ispayable = procdef[4].findChild(it.strVal == "payable") != nil
doAssert(not (ispure and isview),
"can't be both `pure` and `view`")
doAssert(not ((ispure or isview) and ispayable),
"can't be both `pure` or `view` while being `payable`")
if isconstructor:
constructor = some(ConstructorObject(
stateMutability: if ispure: pure elif isview: view elif ispayable: payable else: nonpayable,
inputs: parseInputs(procdef[3]),
outputs: parseOutputs(procdef[3][0])
))
else:
functions.add FunctionObject(
name: procdef[0].strVal,
stateMutability: if ispure: pure elif isview: view elif ispayable: payable else: nonpayable,
inputs: parseInputs(procdef[3]),
outputs: parseOutputs(procdef[3][0])
)
else:
let isanonymous = procdef[4].findChild(it.strVal == "anonymous") != nil
doAssert(procdef[3][0].kind == nnkEmpty,
"Events can't have return values")
events.add EventObject(
name: procdef[0].strVal,
inputs: parseEventInputs(procdef[3]),
anonymous: isanonymous
)
if constructor.isSome:
result.add InterfaceObject(kind: InterfaceObjectKind.constructor, constructorObject: constructor.unsafeGet)
for function in functions:
result.add InterfaceObject(kind: InterfaceObjectKind.function, functionObject: function)
for event in events:
result.add InterfaceObject(kind: InterfaceObjectKind.event, eventObject: event)
macro contract*(cname: untyped, body: untyped): untyped =
var objects = parseContract(body)
result = newStmtList()
let
address = ident "address"
client = ident "client"
receipt = genSym(nskForVar)
receiver = ident "receiver"
eventListener = ident "eventListener"
result.add quote do:
type
`cname`* = object
for obj in objects:
case obj.kind:
of function:
let
signature = getSignature(obj.functionObject)
procName = ident obj.functionObject.name
senderName = ident "sender"
output = if obj.functionObject.outputs.len != 1:
ident "void"
else:
obj.functionObject.outputs[0].typ
var
encodedParams = genSym(nskVar)#newLit("")
offset = genSym(nskVar)
dataBuf = genSym(nskVar)
encodings = genSym(nskVar)
encoder = newStmtList()
encoder.add quote do:
var
`offset` = 0
`encodedParams` = ""
`dataBuf` = ""
`encodings`: seq[EncodeResult]
for input in obj.functionObject.inputs:
let inputName = ident input.name
encoder.add quote do:
let encoding = encode(`inputName`)
`offset` += (if encoding.dynamic:
32
else:
encoding.data.len div 2)
`encodings`.add encoding
encoder.add quote do:
for encoding in `encodings`:
if encoding.dynamic:
`encodedParams` &= `offset`.toHex(64).toLower
`dataBuf` &= encoding.data
else:
`encodedParams` &= encoding.data
`offset` += encoding.data.len div 2
`encodedParams` &= `dataBuf`
var procDef = quote do:
proc `procName`*(`senderName`: Sender[`cname`]): ContractCall[`output`] =
discard
for input in obj.functionObject.inputs:
procDef[3].add nnkIdentDefs.newTree(
ident input.name,
input.typ,
newEmptyNode()
)
procDef[6].add quote do:
`encoder`
return initContractCall[`output`](
`senderName`.web3,
($keccak256.digest(`signature`))[0..<8].toLower & `encodedParams`,
`senderName`.contractAddress)
result.add procDef
of event:
if not obj.eventObject.anonymous:
let callbackIdent = ident "callback"
let jsonIdent = ident "j"
var
params = nnkFormalParams.newTree(newEmptyNode())
paramsWithRawData = nnkFormalParams.newTree(newEmptyNode())
argParseBody = newStmtList()
i = 1
call = nnkCall.newTree(callbackIdent)
callWithRawData = nnkCall.newTree(callbackIdent)
offset = ident "offset"
inputData = ident "inputData"
var offsetInited = false
for input in obj.eventObject.inputs:
let param = nnkIdentDefs.newTree(
ident input.name,
input.typ,
newEmptyNode()
)
params.add param
paramsWithRawData.add param
let
argument = genSym(nskVar)
kind = input.typ
if input.indexed:
argParseBody.add quote do:
var `argument`: `kind`
discard decode(strip0xPrefix(`jsonIdent`["topics"][`i`].getStr), 0, `argument`)
i += 1
else:
if not offsetInited:
argParseBody.add quote do:
var `inputData` = strip0xPrefix(`jsonIdent`["data"].getStr)
var `offset` = 0
offsetInited = true
argParseBody.add quote do:
var `argument`: `kind`
`offset` += decode(`inputData`, `offset`, `argument`)
call.add argument
callWithRawData.add argument
let
eventName = obj.eventObject.name
cbident = ident eventName
procTy = nnkProcTy.newTree(params, newEmptyNode())
signature = getSignature(obj.eventObject)
# generated with dumpAstGen - produces "{.raises: [Defect], gcsafe.}"
let pragmas = nnkPragma.newTree(
nnkExprColonExpr.newTree(
newIdentNode("raises"),
nnkBracket.newTree(
newIdentNode("Defect")
)
),
newIdentNode("gcsafe")
)
procTy[1] = pragmas
callWithRawData.add jsonIdent
paramsWithRawData.add nnkIdentDefs.newTree(
jsonIdent,
bindSym "JsonNode",
newEmptyNode()
)
let procTyWithRawData = nnkProcTy.newTree(paramsWithRawData, newEmptyNode())
procTyWithRawData[1] = pragmas
result.add quote do:
type `cbident`* = object
template eventTopic*(T: type `cbident`): string =
"0x" & toLowerAscii($keccak256.digest(`signature`))
proc subscribe(s: Sender[`cname`],
t: type `cbident`,
options: JsonNode,
`callbackIdent`: `procTy`,
errorHandler: SubscriptionErrorHandler,
withHistoricEvents = true): Future[Subscription] =
let options = addAddressAndSignatureToOptions(options, s.contractAddress, eventTopic(`cbident`))
proc eventHandler(`jsonIdent`: JsonNode) {.gcsafe, raises: [Defect].} =
try:
`argParseBody`
`call`
except CatchableError as err:
errorHandler err[]
s.web3.subscribeForLogs(options, eventHandler, errorHandler, withHistoricEvents)
proc subscribe(s: Sender[`cname`],
t: type `cbident`,
options: JsonNode,
`callbackIdent`: `procTyWithRawData`,
errorHandler: SubscriptionErrorHandler,
withHistoricEvents = true): Future[Subscription] =
let options = addAddressAndSignatureToOptions(options, s.contractAddress, eventTopic(`cbident`))
proc eventHandler(`jsonIdent`: JsonNode) {.gcsafe, raises: [Defect].} =
try:
`argParseBody`
`callWithRawData`
except CatchableError as err:
errorHandler err[]
s.web3.subscribeForLogs(options, eventHandler, errorHandler, withHistoricEvents)
else:
discard
when defined(debugMacros) or defined(debugWeb3Macros):
echo result.repr
proc getJsonLogs*(s: Sender,
EventName: type,
fromBlock, toBlock = none(RtBlockIdentifier),
blockHash = none(BlockHash)): Future[JsonNode] =
mixin eventTopic
var options = newJObject()
options["address"] = %s.contractAddress
var topics = newJArray()
topics.elems.insert(%eventTopic(EventName), 0)
options["topics"] = topics
if blockHash.isSome:
doAssert fromBlock.isNone and toBlock.isNone
options["blockhash"] = %blockHash.unsafeGet
else:
if fromBlock.isSome:
options["fromBlock"] = %fromBlock.unsafeGet
if toBlock.isSome:
options["toBlock"] = %toBlock.unsafeGet
s.web3.provider.eth_getLogs(options)
proc nextNonce*(web3: Web3): Future[Nonce] {.async.} =
if web3.lastKnownNonce.isSome:
inc web3.lastKnownNonce.get
return web3.lastKnownNonce.get
else:
let fromAddress = web3.privateKey.get().toPublicKey().toCanonicalAddress.Address
result = int(await web3.provider.eth_getTransactionCount(fromAddress, "latest"))
web3.lastKnownNonce = some result
proc send*(web3: Web3, c: EthSend): Future[TxHash] {.async.} =
if web3.privateKey.isSome():
var cc = c
if cc.nonce.isNone:
cc.nonce = some(await web3.nextNonce())
let t = "0x" & encodeTransaction(cc, web3.privateKey.get())
return await web3.provider.eth_sendRawTransaction(t)
else:
return await web3.provider.eth_sendTransaction(c)
proc send*(c: ContractCallBase,
value = 0.u256,
gas = 3000000'u64,
gasPrice = 0): Future[TxHash] {.async.} =
let
web3 = c.web3
gasPrice = if web3.privateKey.isSome() or gasPrice != 0: some(gasPrice)
else: none(int)
nonce = if web3.privateKey.isSome(): some(await web3.nextNonce())
else: none(Nonce)
cc = EthSend(
data: "0x" & c.data,
source: web3.defaultAccount,
to: some(c.to),
gas: some(Quantity(gas)),
value: some(value),
nonce: nonce,
gasPrice: gasPrice)
return await web3.send(cc)
proc call*[T](c: ContractCall[T],
value = 0.u256,
gas = 3000000'u64,
blockNumber = high(uint64)): Future[T] {.async.} =
var cc: EthCall
cc.data = some("0x" & c.data)
cc.source = some(c.web3.defaultAccount)
cc.to = c.to
cc.gas = some(Quantity(gas))
cc.value = some(value)
let response = strip0xPrefix:
if blockNumber != high(uint64):
await c.web3.provider.eth_call(cc, &"0x{blockNumber:X}")
else:
await c.web3.provider.eth_call(cc, "latest")
if response.len > 0:
var res: T
discard decode(response, 0, res)
return res
else:
raise newException(CatchableError, "No response from the Web3 provider")
proc getMinedTransactionReceipt*(web3: Web3, tx: TxHash): Future[ReceiptObject] {.async.} =
## Returns the receipt for the transaction. Waits for it to be mined if necessary.
# TODO: Potentially more optimal solution is to subscribe and wait for appropriate
# notification. Now we're just polling every 500ms which should be ok for most cases.
var r: Option[ReceiptObject]
while r.isNone:
r = await web3.provider.eth_getTransactionReceipt(tx)
if r.isNone:
await sleepAsync(500.milliseconds)
result = r.get
proc exec*[T](c: ContractCall[T], value = 0.u256, gas = 3000000'u64): Future[T] {.async.} =
let h = await c.send(value, gas)
let receipt = await c.web3.getMinedTransactionReceipt(h)
# TODO: decode result from receipt
# This call will generate the `cc.data` part to call that contract method in the code below
#sendCoin(fromHex(Stuint[256], "e375b6fb6d0bf0d86707884f3952fee3977251fe"), 600.to(Stuint[256]))
# Set up a JsonRPC call to send a transaction
# The idea here is to let the Web3 object contain the RPC calls, then allow the
# above DSL to create helpers to create the EthSend object and perform the
# transaction. The current idea is to make all this reduce to something like:
# var
# w3 = initWeb3("127.0.0.1", 8545)
# myContract = contract:
# <DSL>
# myContract.sender("0x780bc7b4055941c2cb0ee10510e3fc837eb093c1").sendCoin(
# fromHex(Stuint[256], "e375b6fb6d0bf0d86707884f3952fee3977251fe"),
# 600.to(Stuint[256])
# )
# If the address of the contract on the chain should be part of the DSL or
# dynamically registered is still not decided.
#var cc: EthSend
#cc.source = [0x78.byte, 0x0b, 0xc7, 0xb4, 0x05, 0x59, 0x41, 0xc2, 0xcb, 0x0e, 0xe1, 0x05, 0x10, 0xe3, 0xfc, 0x83, 0x7e, 0xb0, 0x93, 0xc1]
#cc.to = some([0x0a.byte, 0x78, 0xc0, 0x8F, 0x31, 0x4E, 0xB2, 0x5A, 0x35, 0x1B, 0xfB, 0xA9, 0x03,0x21, 0xa6, 0x96, 0x04, 0x74, 0xbD, 0x79])
#cc.data = "0x90b98a11000000000000000000000000e375b6fb6d0bf0d86707884f3952fee3977251FE0000000000000000000000000000000000000000000000000000000000000258"
#var w3 = initWeb3("127.0.0.1", 8545)
#let response = waitFor w3.eth.eth_sendTransaction(cc)
#echo response
proc contractSender*(web3: Web3, T: typedesc, toAddress: Address): Sender[T] =
Sender[T](web3: web3, contractAddress: toAddress)
proc isDeployed*(s: Sender, atBlock: RtBlockIdentifier): Future[bool] {.async.} =
let
codeFut = case atBlock.kind
of bidNumber:
s.web3.provider.eth_getCode(s.contractAddress, atBlock.number)
of bidAlias:
s.web3.provider.eth_getCode(s.contractAddress, atBlock.alias)
code = await codeFut
# TODO: Check that all methods of the contract are present by
# looking for their ABI signatures within the code:
# https://ethereum.stackexchange.com/questions/11856/how-to-detect-from-web3-if-method-exists-on-a-deployed-contract
return code.len > 0
proc subscribe*(s: Sender, t: typedesc, cb: proc): Future[Subscription] {.inline.} =
subscribe(s, t, newJObject(), cb, SubscriptionErrorHandler nil)
proc `$`*(b: Bool): string =
$(StInt[256](b))