Skip to content

Commit

Permalink
feat: use IPNI advertisements from the miner only (#55)
Browse files Browse the repository at this point in the history
1. Resolve `minerId` to miner's PeerId, which is the same value as
   `Provider.ID` in the IPNI records

2. Ignore IPNI records advertised by different miners

3. Report `minerId` and `providerId` as the new measurement fields

Signed-off-by: Miroslav Bajtoš <[email protected]>
  • Loading branch information
bajtos authored Mar 19, 2024
1 parent c6c4491 commit 66202a3
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 19 deletions.
6 changes: 6 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export const SPARK_VERSION = '1.9.1'
export const MAX_CAR_SIZE = 200 * 1024 * 1024 // 200 MB
export const APPROX_ROUND_LENGTH_IN_MS = 20 * 60_000 // 20 minutes

export const RPC_REQUEST = new Request('https://api.node.glif.io/', {
headers: {
authorization: 'Bearer 6bbc171ebfdd78b2644602ce7463c938'
}
})
9 changes: 3 additions & 6 deletions lib/ipni-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { decodeBase64, decodeVarint } from '../vendor/deno-deps.js'
/**
*
* @param {string} cid
* @param {string} providerId
* @returns {Promise<{
* indexerResult: string;
* provider?: { address: string; protocol: string };
* }>}
*/
export async function queryTheIndex (cid) {
export async function queryTheIndex (cid, providerId) {
const url = `https://cid.contact/cid/${encodeURIComponent(cid)}`

let providerResults
Expand All @@ -29,11 +30,7 @@ export async function queryTheIndex (cid) {

let graphsyncProvider
for (const p of providerResults) {
// TODO: find only the contact advertised by the SP handling this deal
// See https://filecoinproject.slack.com/archives/C048DLT4LAF/p1699958601915269?thread_ts=1699956597.137929&cid=C048DLT4LAF
// bytes of CID of dag-cbor encoded DealProposal
// https://github.com/filecoin-project/boost/blob/main/indexprovider/wrapper.go#L168-L172
// https://github.com/filecoin-project/boost/blob/main/indexprovider/wrapper.go#L195
if (p.Provider.ID !== providerId) continue

const [protocolCode] = decodeVarint(decodeBase64(p.Metadata))
const protocol = {
Expand Down
50 changes: 50 additions & 0 deletions lib/miner-info.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { RPC_REQUEST } from './constants.js'

/**
* @param {string} minerId A miner actor id, e.g. `f0142637`
* @returns {Promise<string>} Miner's PeerId, e.g. `12D3KooWMsPmAA65yHAHgbxgh7CPkEctJHZMeM3rAvoW8CZKxtpG`
*/
export async function getMinerPeerId (minerId) {
try {
const res = await rpc('Filecoin.StateMinerInfo', minerId, null)
return res.PeerId
} catch (err) {
err.message = `Cannot obtain miner info for ${minerId}: ${err.message}`
throw err
}
}

/**
* @param {string} method
* @param {unknown[]} params
*/
async function rpc (method, ...params) {
const req = new Request(RPC_REQUEST, {
method: 'POST',
headers: {
'content-type': 'application/json',
accepts: 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params
})
})
const res = await fetch(req)

if (!res.ok) {
throw new Error(`JSON RPC failed with ${res.code}: ${await res.text()}`)
}

const body = await res.json()
if (body.error) {
const err = new Error(body.error.message)
err.name = 'FilecoinRpcError'
err.code = body.code
throw err
}

return body.result
}
51 changes: 42 additions & 9 deletions lib/spark.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { ActivityState } from './activity-state.js'
import { SPARK_VERSION, MAX_CAR_SIZE, APPROX_ROUND_LENGTH_IN_MS } from './constants.js'
import { queryTheIndex } from './ipni-client.js'
import { getMinerPeerId as defaultGetMinerPeerId } from './miner-info.js'
import {
encodeHex
} from '../vendor/deno-deps.js'
Expand All @@ -11,11 +12,16 @@ const sleep = dt => new Promise(resolve => setTimeout(resolve, dt))

export default class Spark {
#fetch
#getMinerPeerId
#activity = new ActivityState()
#maxTasksPerNode = 360

constructor ({ fetch = globalThis.fetch } = {}) {
constructor ({
fetch = globalThis.fetch,
getMinerPeerId = defaultGetMinerPeerId
} = {}) {
this.#fetch = fetch
this.#getMinerPeerId = getMinerPeerId
}

async getRetrieval () {
Expand All @@ -39,12 +45,34 @@ export default class Spark {
}

async executeRetrievalCheck (retrieval, stats) {
console.log(`Calling Filecoin JSON-RPC to get PeerId of miner ${retrieval.minerId}`)
try {
const peerId = await this.#getMinerPeerId(retrieval.minerId)
console.log(`Found peer id: ${peerId}`)
stats.providerId = peerId
} catch (err) {
// There are three common error cases:
// 1. We are offline
// 2. The JSON RPC provider is down
// 3. JSON RPC errors like when Miner ID is not a known actor
// There isn't much we can do in the first two cases. We can notify the user that we are not
// performing any jobs and wait until the problem is resolved.
// The third case should not happen unless we made a mistake, so we want to learn about it
if (err.name === 'FilecoinRpcError') {
// TODO: report the error to Sentry
console.error('The error printed below was not expected, please report it on GitHub:')
console.error('https://github.com/filecoin-station/spark/issues/new')
}
// Abort the check, no measurement should be recorded
throw err
}

console.log(`Querying IPNI to find retrieval providers for ${retrieval.cid}`)
const { indexerResult, provider } = await queryTheIndex(retrieval.cid)
const { indexerResult, provider } = await queryTheIndex(retrieval.cid, stats.providerId)
stats.indexerResult = indexerResult

const providerFound = indexerResult === 'OK' || indexerResult === 'HTTP_NOT_ADVERTISED'
if (!providerFound) return
if (!providerFound) return stats

stats.protocol = provider.protocol
stats.providerAddress = provider.address
Expand Down Expand Up @@ -171,6 +199,7 @@ export default class Spark {
byteLength: 0,
carChecksum: null,
statusCode: null,
providerId: null,
indexerResult: null
}

Expand All @@ -188,12 +217,7 @@ export default class Spark {
await this.nextRetrieval()
this.#activity.onHealthy()
} catch (err) {
if (err.statusCode === 400 && err.serverMessage === 'OUTDATED CLIENT') {
this.#activity.onOutdatedClient()
} else {
this.#activity.onError()
}
console.error(err)
this.handleRunError(err)
}
const duration = Date.now() - started
const baseDelay = APPROX_ROUND_LENGTH_IN_MS / this.#maxTasksPerNode
Expand All @@ -204,6 +228,15 @@ export default class Spark {
}
}
}

handleRunError (err) {
if (err.statusCode === 400 && err.serverMessage === 'OUTDATED CLIENT') {
this.#activity.onOutdatedClient()
} else {
this.#activity.onError()
}
console.error(err)
}
}

async function assertOkResponse (res, errorMsg) {
Expand Down
1 change: 1 addition & 0 deletions test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './test/ipni-client.test.js'
import './test/miner-info.test.js'
import './test/integration.js'
import './test/spark.js'
19 changes: 16 additions & 3 deletions test/integration.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import Spark from '../lib/spark.js'
import { test } from 'zinnia:test'

import { assert, assertEquals } from 'zinnia:assert'
import { test } from 'zinnia:test'

const KNOWN_CID = 'bafkreih25dih6ug3xtj73vswccw423b56ilrwmnos4cbwhrceudopdp5sq'
const OUR_FAKE_MINER_ID = 'f01spark'
const FRISBEE_PEER_ID = '12D3KooWN3zbfCjLrjBB7uxYThRTCFM9nxinjb5j9fYFZ6P5RUfP'

test('integration', async () => {
const spark = new Spark()
Expand All @@ -15,15 +18,25 @@ test('integration', async () => {
})

test('retrieval check for our CID', async () => {
const spark = new Spark()
spark.getRetrieval = async () => ({ cid: KNOWN_CID })
const minersChecked = []
const getMinerPeerId = async (minerId) => {
minersChecked.push(minerId)
return FRISBEE_PEER_ID
}
const spark = new Spark({ getMinerPeerId })
spark.getRetrieval = async () => ({ cid: KNOWN_CID, minerId: OUR_FAKE_MINER_ID })

const measurementId = await spark.nextRetrieval()
const res = await fetch(`https://api.filspark.com/measurements/${measurementId}`)
assert(res.ok)
const m = await res.json()
const assertProp = (prop, expectedValue) => assertEquals(m[prop], expectedValue, prop)

assertEquals(minersChecked, [OUR_FAKE_MINER_ID])

assertProp('cid', KNOWN_CID)
assertProp('minerId', OUR_FAKE_MINER_ID)
assertProp('providerId', FRISBEE_PEER_ID)
assertProp('indexerResult', 'OK')
assertProp('providerAddress', '/dns/frisbii.fly.dev/tcp/443/https')
assertProp('protocol', 'http')
Expand Down
8 changes: 7 additions & 1 deletion test/ipni-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { assertEquals } from 'zinnia:assert'
import { queryTheIndex } from '../lib/ipni-client.js'

const KNOWN_CID = 'bafkreih25dih6ug3xtj73vswccw423b56ilrwmnos4cbwhrceudopdp5sq'
const FRISBEE_PEER_ID = '12D3KooWN3zbfCjLrjBB7uxYThRTCFM9nxinjb5j9fYFZ6P5RUfP'

test('query advertised CID', async () => {
const result = await queryTheIndex(KNOWN_CID)
const result = await queryTheIndex(KNOWN_CID, FRISBEE_PEER_ID)
assertEquals(result, {
indexerResult: 'OK',
provider: {
Expand All @@ -14,3 +15,8 @@ test('query advertised CID', async () => {
}
})
})

test('ignore advertisements from other miners', async () => {
const result = await queryTheIndex(KNOWN_CID, '12D3KooWsomebodyelse')
assertEquals(result.indexerResult, 'NO_VALID_ADVERTISEMENT')
})
21 changes: 21 additions & 0 deletions test/miner-info.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test } from 'zinnia:test'
import { assertMatch, AssertionError } from 'zinnia:assert'
import { getMinerPeerId } from '../lib/miner-info.js'

const KNOWN_MINER_ID = 'f0142637'

test('get peer id of a known miner', async () => {
const result = await getMinerPeerId(KNOWN_MINER_ID)
assertMatch(result, /^12D3KooW/)
})

test('get peer id of a miner that does not exist', async () => {
try {
const result = await getMinerPeerId('f010')
throw new AssertionError(
`Expected "getMinerPeerId()" to fail, but it resolved with "${result}" instead.`
)
} catch (err) {
assertMatch(err.toString(), /\bf010\b.*\bactor code is not miner/)
}
})

0 comments on commit 66202a3

Please sign in to comment.