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: peer id smart contract support #117

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

NikolasHaimerl
Copy link
Contributor

@NikolasHaimerl NikolasHaimerl commented Mar 10, 2025

This PR implements support for resolving miner PeerIDs from both traditional on-chain sources and the new Curio smart contract. It addresses the issue of Curio storage providers having their Spark scores drop to zero due to using different PeerIDs for on-chain state and index provider advertisements.

Context

Changes

  • Refactors getMinerPeerId to query both sources concurrently using Promise.allSettled
  • Prioritizes the smart contract result, falling back to the RPC node result if necessary
  • Adds new getMinerPeerIdFromRpcNode function with the original implementation
  • Implements getMinerPeerIdFromSmartContract to query the Curio contract at 0x14183aD016Ddc83D638425D6328009aa390339Ce
  • Adds comprehensive tests for all resolution paths

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

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

The implementation looks good at the high level, let's improve the details now.

Comment on lines +38 to +39
// Check contract result first
if (contractResult.status === 'fulfilled' && contractResult.value) {
Copy link
Member

Choose a reason for hiding this comment

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

What if the smart contract state query failed? Then we don't know whether the miner is setting a custom peer ID in the smart contract or whether it's ok to fall back to the MinerInfo peer ID.

I propose to fail the entire getMinerPeerId in case that any of the RPC API requests fails.

That should also simplify the implementation, as you can use Promise.all and don't have to deal with status === 'fulfilled'.

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Are there no cases where a miner's peer ID is recorded on IPNI but not in the smart contract?

Copy link

Choose a reason for hiding this comment

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

+1 on using Promise.all

Copy link
Member

@bajtos bajtos Mar 11, 2025

Choose a reason for hiding this comment

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

Let me explain:

  • Every miner has a PeerID recorded in MinerInfo state. This PeerID is used for peer-to-peer communication between Filecoin nodes building a consensus about the blockchain ledger.
  • It happens that both Venus and Boost use this same PeerID for signing IPNI advertisements.
  • Venus and Boost don't have any record in the smart contract mapping.
  • Curio uses a different PeerID for p2p communication with other Filecoin nodes and a different one for signing IPNI advertisements. You can learn more about this topic in the Curio doc page I linked in one of my comments.
  • Curio miners will have one PeerID in MinerInfo state and another PeerID in the smart contract mapping. We want to use the value from the smart contract.

Are there no cases where a miner's peer ID is recorded on IPNI but not in the smart contract?

Yes, there are many such cases - all Venus and Boost miners.

Your question describes a different topic from what I meant in my comment, though.

I am talking about the situation when the RPC API call fails, and thus we don't know the answers to the questions "what's miner's PeerID in MinerInfo state" and "what's miner's PeerID in the smart contract mapping".

As I understand your code:

  1. When the RPC API call succeeds, but no mapping is found, the function returns null.
  2. When the RPC API fails, the function throws an error.

I am asking for a more robust handling of the second option.

An example case - can you encode it in a new test?

  • A miner is running Curio and has an entry in the smart contract mapping with PeerID ID1.
  • The miner has PeerID ID2 in the MinerInfo state.
  • On the Spark side, the RPC API call to read smart contract state fails, but the RPC API call to read MinerInfo state succeeds.
  • In this case, we must not return ID2 as the miner's PeerID, because that's not correct.

Copy link
Contributor Author

@NikolasHaimerl NikolasHaimerl Mar 11, 2025

Choose a reason for hiding this comment

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

Thanks for the explaination. If a miner has peer ids on the smart contract as well as in MinerInfo, I don't think we can tell the difference between The call to the smart contract failed and There is no peer id on the smart contract and the call failed.

Copy link
Member

Choose a reason for hiding this comment

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

  • In the first case, I expect you get back the "empty string" response.
  • In the second case, I expect the call to throw a JavaScript-level error.

Copy link
Member

Choose a reason for hiding this comment

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

a real smart contract client connected to Glif but with no authorization header set

This is just a quick way to simulate an outage of the RPC API service. In practice, we will be seeing different errors, but the mechanism and response format should be the same.

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 am not 100% sure I understood your concerns with the current design. I added some tests in 5b63a20 to illustrate the expected behaviour of my approach.

Copy link
Member

Choose a reason for hiding this comment

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

Using a real smart contract client connected to Glif but with no authorization header set

This is unfortunately not enough to trigger an error response. Here is an alternative you can use for a one-time verification of how Ethers.js handle transport-level errors:

  const fetchRequest = new ethers.FetchRequest('https://api.node.glif.io/rpc/vx')

I am not 100% sure I understood your concerns with the current design.

Here is the code demonstrating the problem - I placed the code to debug.js and executed zinnia run debug.js:

import { getMinerPeerId } from './lib/miner-info.js'

const peerId = await getMinerPeerId('f03303347', {
  smartContract: {
    getPeerData: async () => {
      throw new Error('RPC CALL ERROR')
    }
  }
})
console.log('PeerID:     ', peerId)

const realPeerId = await getMinerPeerId('f03303347')
console.log('Real PeerID:', realPeerId)

Output - notice that the first getMinerPeerId invocation returns a wrong PeerID value.

Using PeerID from FilecoinMinerInfo.
PeerID:      12D3KooWCtiN7tAjeLKL4mashteXdH4htUrzWu8bWN9kDU3qbKjQ
Using PeerID from the smart contract.
Real PeerID: 12D3KooWJ91c6xQshrNe7QAXPFAaeRrHWq2UrgXGPf8UmMZMwyZ5

Expected behaviour: The first call of getMinerPeerId(), where the RPC API call querying the smart contract fails, must not return an incorrect PeerID value. I think it should throw an error, but I am open to discussing other ways to achieve that.

We suggested Promise.all() because it gives us the behaviour I asked for - if any of the RPC API calls fails, then the entire getMinerPeerId() call fails.

 const [minerInfoResult, contractResult] = await Promise.all([
   getMinerPeerIdFromFilecoinMinerInfo(minerId, { maxAttempts, rpcFn }),
   getMinerPeerIdFromSmartContract(minerId, { smartContract })
 ])

  if (contractResult) {
    console.log('Using PeerID from the smart contract.')
    return contractResult
  }

  if (minerInfoResult) {
    console.log('Using PeerID from FilecoinMinerInfo.')
    return minerInfoResult
  }

I suppose that's suboptimal in case the smart-contract query succeeds (and finds a PeerID value) and the MinerInfo query fails (which does not matter because we will use the value found in the smart contract anyways). Personally, I think this is such a minor edge case that it's not worth fixing, considering the additional complexity required.

However, if you prefer to keep your current Promise.allSettled version, then I am fine with that.

Just make sure the test case I shown above is handled correctly.

Copy link
Contributor Author

@NikolasHaimerl NikolasHaimerl Mar 12, 2025

Choose a reason for hiding this comment

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

We have a coupld of different scenarios:

Peer Id in IPNI | Peer Id in Smart Contract | IPNI Call | Smart Contract Call | Expected Behavior
True | True | Success | Success  |  Smart Contract Peer Id is chosen
True | True | Success | Fail  |  Smart Contract Peer Id is chosen
True | True | Fail | Success  |  RPC Peer Id is chosen (We do not know whether the Smart Contract Peer Id is different to the RPC Peer Id or if there simply is no Smart Contract Peer Id)
True | True | Fail | Fail  |  Throws Error that it cannot find anything
True | False | Success | Fail  |  Smart Contract Peer Id is chosen
True | False | Fail | Fail  |  Throws Error that it cannot find anything
False | True | Fail | Success |  RPC Peer Id is chosen
False | True | Fail | Fail |  Throws Error that it cannot find anything
False | False | Fail | Fail | Throws Error that it cannot find anything

With Promise.allSettled we achieve this behaviour. If we were to use Promise.all we cannot process the cases:

  • True | False | Success | Fail |  Smart Contract Peer Id is chosen
  • True | True | Fail | Success |  RPC Peer Id is chosen (We do not know whether the Smart Contract Peer Id is different to the RPC Peer Id or if there simply is no Smart Contract Peer Id)
  • False | True | Fail | Success |  RPC Peer Id is chosen
  • True | True | Success | Fail |  Smart Contract Peer Id is chosen
    Which I would argue is not what we want. This is why I would go with Promise.allSettled.

@NikolasHaimerl NikolasHaimerl marked this pull request as ready for review March 10, 2025 15:25
@NikolasHaimerl NikolasHaimerl requested a review from pyropy March 10, 2025 15:25
@NikolasHaimerl NikolasHaimerl self-assigned this Mar 10, 2025
@NikolasHaimerl NikolasHaimerl requested a review from bajtos March 11, 2025 05:24
Copy link

@pyropy pyropy left a comment

Choose a reason for hiding this comment

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

Good job @NikolasHaimerl 👏🏻

Comment on lines +38 to +39
// Check contract result first
if (contractResult.status === 'fulfilled' && contractResult.value) {
Copy link

Choose a reason for hiding this comment

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

+1 on using Promise.all

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

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

You are making great progress!

The biggest item to resolve is handling of RPC API errors + building a better understanding what response to expect for miners with no PeerID mapping set in the smart contract.

Comment on lines +38 to +39
// Check contract result first
if (contractResult.status === 'fulfilled' && contractResult.value) {
Copy link
Member

Choose a reason for hiding this comment

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

If a miner has peer ids on the smart contract as well as in MinerInfo, I don't think we can tell the difference between The call to the smart contract failed and There is no peer id on the smart contract and the call failed.

I don't know the answer, but what you described does not match my mental model of how these things work.

Here is what you can do to verify our hypotheses:

  • Using a real smart contract client connected to Glif, call await contractClient.getPeerData(3501639). (This is a client ID; I don't expect it to have a PeerID mapping in the smart contract.) What response do you get back?
  • Using a real smart contract client connected to Glif but with no authorization header set, call await contractClient.getPeerData(3303347). (This is a miner ID from your integration test that has a PeerID mapping entry.) What happens?

Comment on lines +38 to +39
// Check contract result first
if (contractResult.status === 'fulfilled' && contractResult.value) {
Copy link
Member

Choose a reason for hiding this comment

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

  • In the first case, I expect you get back the "empty string" response.
  • In the second case, I expect the call to throw a JavaScript-level error.

Comment on lines +38 to +39
// Check contract result first
if (contractResult.status === 'fulfilled' && contractResult.value) {
Copy link
Member

Choose a reason for hiding this comment

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

a real smart contract client connected to Glif but with no authorization header set

This is just a quick way to simulate an outage of the RPC API service. In practice, we will be seeing different errors, but the mechanism and response format should be the same.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

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

The updated implementation (lib files) looks pretty good to me now 👍🏻

Now we need to address this discussion about handling RPC API errors:
#117 (comment)

I'll review the tests updated since my previous review after that discussion is resolved.

import { getMinerPeerId } from '../lib/miner-info.js'
import { test } from 'zinnia:test'

const mockPeerIdResponse = {
Copy link
Member

Choose a reason for hiding this comment

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

The name mockPeerIdResponse is very similar to mockEmptyPeerIdResponse, I had troubles spotting the difference when reading the tests below. Let's use more distinct names please.

For example:

Suggested change
const mockPeerIdResponse = {
const mockValidPeerIdResponse = {

Also, the prefix "mock" is not suitable here - mocks are objects/functions with active behaviour. See https://martinfowler.com/bliki/TestDouble.html

Can we simply drop the mock prefix and use the names validPeerIdResponse and emptyPeerIdResponse instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 9a8a972

NikolasHaimerl and others added 2 commits March 12, 2025 16:32
Co-authored-by: Miroslav Bajtoš <oss@bajtos.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: No status
Development

Successfully merging this pull request may close these issues.

3 participants