Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add incentivization PoC for RLNaaS in Lightpush #3166

Open
wants to merge 29 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a3bef81
feat: add simple txid-based eligibility check with hard-coded params …
s-tikhomirov Oct 31, 2024
22852d6
use new proc to generate eligibility status
s-tikhomirov Dec 11, 2024
83953a5
minor fixes
s-tikhomirov Dec 11, 2024
4e956c4
add comments to clarify eligibility definition
s-tikhomirov Dec 11, 2024
5ddf3af
use Address.fromHex conversion from eth-web3
s-tikhomirov Dec 11, 2024
5167eb6
move isEligible to common
s-tikhomirov Dec 12, 2024
829c1f6
refactor: avoid result and unnecesary branching
s-tikhomirov Dec 12, 2024
718c772
define const for simple transfer gas usage
s-tikhomirov Dec 12, 2024
d7f2a33
avoid unnecessary parentheses
s-tikhomirov Dec 12, 2024
363d56c
chore: run nph linter manually
s-tikhomirov Dec 13, 2024
ef4c2d3
refactor, move all hard-coded constants to tests
s-tikhomirov Dec 13, 2024
03eb690
use Result type in eligibility tests
s-tikhomirov Dec 13, 2024
a92ffc7
use standard method of error handling
s-tikhomirov Dec 13, 2024
4b60383
make try-block smaller
s-tikhomirov Dec 13, 2024
586dadd
add a try-block in case of connection failure to web3 provider
s-tikhomirov Dec 13, 2024
e517eca
make queries to web3 provider in parallel
s-tikhomirov Dec 13, 2024
3b5ea31
move Web3 provider RPC URL into env variable
s-tikhomirov Dec 13, 2024
3356d40
remove unused import
s-tikhomirov Dec 13, 2024
4ad3bda
rename functions
s-tikhomirov Dec 17, 2024
4c57cbc
use await in async proc
s-tikhomirov Dec 17, 2024
e9df168
add timeout to tx receipt query
s-tikhomirov Dec 20, 2024
af16879
parallelize queries for tx and txreceipt
s-tikhomirov Dec 20, 2024
c7d4e68
make test txids non public
s-tikhomirov Jan 3, 2025
2bb2d0f
use assert in txid i13n test
s-tikhomirov Jan 3, 2025
d9a86c8
use parentheses when calling verb-methods without arguments
s-tikhomirov Jan 3, 2025
3d026e7
remove unused import
s-tikhomirov Jan 3, 2025
133a411
use init for stack-allocated objects
s-tikhomirov Jan 3, 2025
60aaed6
add txReceipt error message to error
s-tikhomirov Jan 3, 2025
ccec7f8
[WIP] introduce eligibility manager
s-tikhomirov Jan 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/incentivization/test_all.nim
Original file line number Diff line number Diff line change
@@ -1 +1 @@
import ./test_rpc_codec
import ./test_rpc_codec, ./test_poc
79 changes: 79 additions & 0 deletions tests/incentivization/test_poc.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{.used.}

import
std/[options], testutils/unittests, chronos, web3, stew/byteutils, stint, strutils, os

import
waku/[node/peer_manager, waku_core],
../testlib/[assertions],
waku/incentivization/[rpc, rpc_codec, common, txid_proof]

# All txids from Ethereum Sepolia testnet
const TxHashNonExisting* =
TxHash.fromHex("0x0000000000000000000000000000000000000000000000000000000000000000")
const TxHashContractCreation* =
TxHash.fromHex("0xa2e39bee557144591fb7b2891ef44e1392f86c5ba1fc0afb6c0e862676ffd50f")
const TxHashContractCall* =
TxHash.fromHex("0x2761f066eeae9a259a0247f529133dd01b7f57bf74254a64d897433397d321cb")
const TxHashSimpleTransfer* =
TxHash.fromHex("0xa3985984b2ec3f1c3d473eb57a4820a56748f25dabbf9414f2b8380312b439cc")
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
const ExpectedToAddress = Address.fromHex("0x5e809a85aa182a9921edd10a4163745bb3e36284")
const ExpectedValue = 200500000000005063.u256

# To set up the environment variable (replace Infura with your provider if needed):
# $ export WEB3_RPC_URL="https://sepolia.infura.io/v3/YOUR_API_KEY"
const EthClient = os.getEnv("WEB3_RPC_URL")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is generally better to avoid external dependencies in tests. I suggest taking a look on how Anvil is being used in other RLN tests.

For example, tests/waku_rln_relay/test_rln_group_manager_onchain.nim and waku/waku_rln_relay/constants.nim can serve as a good reference :) ( notice EthClient* = "http://127.0.0.1:8540" is defined in constants and it deals with a local Anvil instance )


suite "Waku Incentivization PoC Eligibility Proofs":
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
## Tests for service incentivization PoC.
## In a client-server interaction, a client submits an eligibility proof to the server.
## The server provides the service if and only if the proof is valid.
## In PoC, a txid serves as eligibility proof.
## The txid reflects the confirmed payment from the client to the server.
## The request is eligible if the tx is confirmed and pays the correct amount to the correct address.
## The tx must also be of a "simple transfer" type (not a contract creation, not a contract call).
## See spec: https://github.com/waku-org/specs/blob/master/standards/core/incentivization.md

asyncTest "incentivization PoC: non-existent tx is not eligible":
## Test that an unconfirmed tx is not eligible.
let eligibilityProof =
EligibilityProof(proofOfPayment: some(@(TxHashNonExisting.bytes())))
let isEligible = await isEligibleTxId(
eligibilityProof, ExpectedToAddress, ExpectedValue, EthClient
)
check:
isEligible.isErr()

asyncTest "incentivization PoC: contract creation tx is not eligible":
## Test that a contract creation tx is not eligible.
let eligibilityProof =
EligibilityProof(proofOfPayment: some(@(TxHashContractCreation.bytes())))
let isEligible = await isEligibleTxId(
eligibilityProof, ExpectedToAddress, ExpectedValue, EthClient
)
check:
isEligible.isErr()

asyncTest "incentivization PoC: contract call tx is not eligible":
## Test that a contract call tx is not eligible.
## This assumes a payment in native currency (ETH), not a token.
let eligibilityProof =
EligibilityProof(proofOfPayment: some(@(TxHashContractCall.bytes())))
let isEligible = await isEligibleTxId(
eligibilityProof, ExpectedToAddress, ExpectedValue, EthClient
)
check:
isEligible.isErr()

asyncTest "incentivization PoC: simple transfer tx is eligible":
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
## Test that a simple transfer tx is eligible (if necessary conditions hold).
let eligibilityProof =
EligibilityProof(proofOfPayment: some(@(TxHashSimpleTransfer.bytes())))
let isEligible = await isEligibleTxId(
eligibilityProof, ExpectedToAddress, ExpectedValue, EthClient
)
check:
isEligible.isOk()
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved

# TODO: add tests for simple transfer txs with wrong amount and wrong receiver
# TODO: add test for failing Web3 provider
29 changes: 13 additions & 16 deletions tests/incentivization/test_rpc_codec.nim
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
import
std/options,
std/strscans,
testutils/unittests,
chronicles,
chronos,
libp2p/crypto/crypto
import std/options, testutils/unittests, chronos, libp2p/crypto/crypto, web3

import waku/incentivization/rpc, waku/incentivization/rpc_codec
import waku/incentivization/[rpc, rpc_codec, common]

suite "Waku Incentivization Eligibility Codec":
asyncTest "encode eligibility proof":
var byteSequence: seq[byte] = @[1, 2, 3, 4, 5, 6, 7, 8]
let epRpc = EligibilityProof(proofOfPayment: some(byteSequence))
let encoded = encode(epRpc)
asyncTest "encode eligibility proof from txid":
let txHash = TxHash.fromHex(
"0x0000000000000000000000000000000000000000000000000000000000000000"
)
let txHashAsBytes = @(txHash.bytes())
let eligibilityProof = EligibilityProof(proofOfPayment: some(txHashAsBytes))
let encoded = encode(eligibilityProof)
let decoded = EligibilityProof.decode(encoded.buffer).get()
check:
epRpc == decoded
eligibilityProof == decoded

asyncTest "encode eligibility status":
let esRpc = EligibilityStatus(statusCode: uint32(200), statusDesc: some("OK"))
let encoded = encode(esRpc)
let eligibilityStatus = new(EligibilityStatus, true)
let encoded = encode(eligibilityStatus)
let decoded = EligibilityStatus.decode(encoded.buffer).get()
check:
esRpc == decoded
eligibilityStatus == decoded
9 changes: 9 additions & 0 deletions waku/incentivization/common.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import std/options, chronos
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved

import waku/incentivization/[rpc, txid_proof]

proc new*(T: type EligibilityStatus, isEligible: bool): T =
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
if isEligible:
EligibilityStatus(statusCode: uint32(200), statusDesc: some("OK"))
else:
EligibilityStatus(statusCode: uint32(402), statusDesc: some("Payment Required"))
3 changes: 1 addition & 2 deletions waku/incentivization/rpc.nim
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json_serialization, std/options
import ../waku_core
import std/options

# Implementing the RFC:
# https://github.com/vacp2p/rfc/tree/master/content/docs/rfcs/73
Expand Down
87 changes: 87 additions & 0 deletions waku/incentivization/txid_proof.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import std/options, chronos, web3, stew/byteutils, stint, results, chronicles

import waku/incentivization/rpc

const SimpleTransferGasUsed = Quantity(21000)
const TxReceiptQueryTimeout = 3.seconds

proc getTransactionByHash(
txHash: TxHash, web3: Web3
): Future[TransactionObject] {.async.} =
await web3.provider.eth_getTransactionByHash(txHash)

proc getMinedTransactionReceipt(
txHash: TxHash, web3: Web3
): Future[Result[ReceiptObject, string]] {.async.} =
let txReceipt = web3.getMinedTransactionReceipt(txHash)
if (await txReceipt.withTimeout(TxReceiptQueryTimeout)):
return ok(txReceipt.value())
else:
return err("Timeout on tx receipt query")

proc getTxAndTxReceipt(
txHash: TxHash, web3: Web3
): Future[Result[(TransactionObject, ReceiptObject), string]] {.async.} =
let txFuture = getTransactionByHash(txHash, web3)
let receiptFuture = getMinedTransactionReceipt(txHash, web3)
await allFutures(txFuture, receiptFuture)
let tx = txFuture.read()
let txReceipt = receiptFuture.read()
if txReceipt.isErr:
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
return err("Cannot get tx receipt")
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
return ok((tx, txReceipt.get()))

proc isEligibleTxId*(
eligibilityProof: EligibilityProof,
expectedToAddress: Address,
expectedValue: UInt256,
ethClient: string,
): Future[Result[void, string]] {.async.} =
## We consider a tx eligible,
## in the context of service incentivization PoC,
## if it is confirmed and pays the expected amount to the server's address.
## See spec: https://github.com/waku-org/specs/blob/master/standards/core/incentivization.md
if eligibilityProof.proofOfPayment.isNone:
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
return err("Eligibility proof is empty")
var web3: Web3
try:
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
web3 = await newWeb3(ethClient)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new instance of web3 for every call to isEligibleTxId doesn't sound very efficient. I believe it is better to do that once, when the app starts, and only close the web3 instance at the app's end.

I'd suggest creating a new type EligibilityManager that contains the web3 instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the idea high-level and it makes total sense. I started implementing it in ccec7f8 (for now, it replicates the current behavior, just with the EligibilityManager instance on top).

I'm not sure about the next steps though. How to make the manager start "when the app starts" and close "at the app's end"? Where are the app's start and end defined? Do we have similar examples in our codebase I should look into? I'd note also that this PR doesn't intend to integrate the funcitonality into the app itself; all eligibility-related functions are only called from tests. Is this detail relevant?

I'd appreciate any pointers!

except ValueError:
let errorMsg =
"Failed to set up a web3 provider connection: " & getCurrentExceptionMsg()
error "exception in isEligibleTxId", error = $errorMsg
return err($errorMsg)
var tx: TransactionObject
var txReceipt: ReceiptObject
let txHash = TxHash.fromHex(byteutils.toHex(eligibilityProof.proofOfPayment.get()))
try:
let txAndTxReceipt = await getTxAndTxReceipt(txHash, web3)
txAndTxReceipt.isOkOr:
return err("Failed to fetch tx or tx receipt")
(tx, txReceipt) = txAndTxReceipt.value()
except ValueError:
let errorMsg = "Failed to fetch tx or tx receipt: " & getCurrentExceptionMsg()
error "exception in isEligibleTxId", error = $errorMsg
return err($errorMsg)
# check that it is not a contract creation tx
let toAddressOption = txReceipt.to
if toAddressOption.isNone:
s-tikhomirov marked this conversation as resolved.
Show resolved Hide resolved
# this is a contract creation tx
return err("A contract creation tx is not eligible")
# check that it is a simple transfer (not a contract call)
# a simple transfer uses 21000 gas
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we can ensure that comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strictly speaking, we cannot.

The number 21000 comes from the Ethereum spec (yellow paper) as the gas cost that every transaction pays. A simple transfer pays just that, and a contract call pays extra costs on top.

Note: the Yellow paper is outdated, as is admitted in its repo:

The Yellow Paper is out of date. It reflects the Ethereum specification up to the Shanghai network upgrade, activated on the Ethereum mainnet at block 17_034_870 (April 2023).
It does not yet contain changes introduced by the Cancun upgrade.
An alternative Python Execution Layer specification is actively maintained and up to date.

TBH, I don't know how to make sense of that last link. But I would assume that as long as the 21000 constant has remained unchanged throughout Ethereum's history (AFAIK), it's unlikely to be changed.

Another question is that if the payments in question happen on a rollup, technically, the rollup developers can tweak gas costs for their EVM implementation. Linea, for one, also uses 21000 here.

In summary, the assumption "a tx is a simple transfer if and only if it uses 21000 gas" is somewhat of a hack, but haven't come up with a better way to check whether the tx is a contract call or not.

let gasUsed = txReceipt.gasUsed
let isSimpleTransferTx = (gasUsed == SimpleTransferGasUsed)
if not isSimpleTransferTx:
return err("A contract call tx is not eligible")
# check that the to address is "as expected"
let toAddress = toAddressOption.get()
if toAddress != expectedToAddress:
return err("Wrong destination address: " & $toAddress)
# check that the amount is "as expected"
let txValue = tx.value
if txValue != expectedValue:
return err("Wrong tx value: got " & $txValue & ", expected " & $expectedValue)
defer:
await web3.close()
return ok()
Loading