Skip to content

Commit 038d4b7

Browse files
authored
feat: resolve the retrieval provider using IPNI (#51)
Signed-off-by: Miroslav Bajtoš <[email protected]>
1 parent 74e69e0 commit 038d4b7

File tree

9 files changed

+229
-112
lines changed

9 files changed

+229
-112
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ jobs:
55
runs-on: ubuntu-latest
66
steps:
77
- uses: actions/checkout@v3
8-
- run: curl -L https://github.com/filecoin-station/zinnia/releases/download/v0.14.0/zinnia-linux-x64.tar.gz | tar -xz
8+
- run: curl -L https://github.com/filecoin-station/zinnia/releases/download/v0.16.0/zinnia-linux-x64.tar.gz | tar -xz
99
- uses: actions/setup-node@v3
1010
- run: npx standard
1111
- run: ./zinnia run test.js

deps.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// 3rd-party dependencies from Denoland
2+
//
3+
// Run the following script after making change in this file:
4+
// deno bundle deps.ts vendor/deno-deps.js
5+
//
6+
7+
export { encodeHex } from 'https://deno.land/[email protected]/encoding/hex.ts'
8+
export { decodeBase64 } from 'https://deno.land/[email protected]/encoding/base64.ts'
9+
export { decode as decodeVarint } from 'https://deno.land/x/[email protected]/varint.ts'

lib/deno-encoding-hex.js

Lines changed: 0 additions & 93 deletions
This file was deleted.

lib/ipni-client.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { decodeBase64, decodeVarint } from '../vendor/deno-deps.js'
2+
3+
/**
4+
*
5+
* @param {string} cid
6+
* @returns {Promise<{
7+
* indexerResult: string;
8+
* provider?: { address: string; protocol: string };
9+
* }>}
10+
*/
11+
export async function queryTheIndex (cid) {
12+
const url = `https://cid.contact/cid/${encodeURIComponent(cid)}`
13+
14+
let providerResults
15+
try {
16+
const res = await fetch(url)
17+
if (!res.ok) {
18+
console.error('IPNI query failed, HTTP response: %s %s', res.status, await res.text())
19+
return { indexerResult: `ERROR_${res.status}` }
20+
}
21+
22+
const result = await res.json()
23+
providerResults = result.MultihashResults.flatMap(r => r.ProviderResults)
24+
console.log('IPNI returned %s provider results', providerResults.length)
25+
} catch (err) {
26+
console.error('IPNI query failed.', err)
27+
return { indexerResult: 'ERROR_FETCH' }
28+
}
29+
30+
let graphsyncProvider
31+
for (const p of providerResults) {
32+
// TODO: find only the contact advertised by the SP handling this deal
33+
// See https://filecoinproject.slack.com/archives/C048DLT4LAF/p1699958601915269?thread_ts=1699956597.137929&cid=C048DLT4LAF
34+
// bytes of CID of dag-cbor encoded DealProposal
35+
// https://github.com/filecoin-project/boost/blob/main/indexprovider/wrapper.go#L168-L172
36+
// https://github.com/filecoin-project/boost/blob/main/indexprovider/wrapper.go#L195
37+
38+
const [protocolCode] = decodeVarint(decodeBase64(p.Metadata))
39+
const protocol = {
40+
0x900: 'bitswap',
41+
0x910: 'graphsync',
42+
0x0920: 'http',
43+
4128768: 'graphsync'
44+
}[protocolCode]
45+
46+
const address = p.Provider.Addrs[0]
47+
if (!address) continue
48+
49+
switch (protocol) {
50+
case 'http':
51+
return {
52+
indexerResult: 'OK',
53+
provider: { address, protocol }
54+
}
55+
56+
case 'graphsync':
57+
if (!graphsyncProvider) {
58+
graphsyncProvider = {
59+
address: `${address}/p2p/${p.Provider.ID}`,
60+
protocol
61+
}
62+
}
63+
}
64+
}
65+
if (graphsyncProvider) {
66+
console.log('HTTP protocol is not advertised, falling back to Graphsync.')
67+
return {
68+
indexerResult: 'HTTP_NOT_ADVERTISED',
69+
provider: graphsyncProvider
70+
}
71+
}
72+
73+
console.log('All advertisements are for unsupported protocols.')
74+
return { indexerResult: 'NO_VALID_ADVERTISEMENT' }
75+
}

lib/spark.js

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import { ActivityState } from './activity-state.js'
44
import { SPARK_VERSION, MAX_CAR_SIZE, APPROX_ROUND_LENGTH_IN_MS } from './constants.js'
5-
import { encodeHex } from './deno-encoding-hex.js'
5+
import { queryTheIndex } from './ipni-client.js'
6+
import {
7+
encodeHex
8+
} from '../vendor/deno-deps.js'
69

710
const sleep = dt => new Promise(resolve => setTimeout(resolve, dt))
811

@@ -35,8 +38,34 @@ export default class Spark {
3538
return retrieval
3639
}
3740

41+
async executeRetrievalCheck (retrieval, stats) {
42+
console.log(`Querying IPNI to find retrieval providers for ${retrieval.cid}`)
43+
const { indexerResult, provider } = await queryTheIndex(retrieval.cid)
44+
stats.indexerResult = indexerResult
45+
46+
if (indexerResult !== 'OK') return
47+
48+
stats.protocol = provider.protocol
49+
stats.providerAddress = provider.address
50+
51+
const searchParams = new URLSearchParams({
52+
// See https://github.com/filecoin-project/lassie/blob/main/docs/HTTP_SPEC.md#dag-scope-request-query-parameter
53+
// Only the root block at the end of the path is returned after blocks required to verify the specified path segments.
54+
'dag-scope': 'block',
55+
protocols: provider.protocol,
56+
providers: provider.address
57+
})
58+
const url = `ipfs://${retrieval.cid}?${searchParams.toString()}`
59+
try {
60+
await this.fetchCAR(url, stats)
61+
} catch (err) {
62+
console.error(`Failed to fetch ${url}`)
63+
console.error(err)
64+
}
65+
}
66+
3867
async fetchCAR (url, stats) {
39-
console.log(`Fetching ${url}...`)
68+
console.log(`Fetching: ${url}`)
4069

4170
// Abort if no progress was made for 60 seconds
4271
const controller = new AbortController()
@@ -140,23 +169,12 @@ export default class Spark {
140169
carTooLarge: false,
141170
byteLength: 0,
142171
carChecksum: null,
143-
statusCode: null
144-
}
145-
const searchParams = new URLSearchParams({
146-
// See https://github.com/filecoin-project/lassie/blob/main/docs/HTTP_SPEC.md#dag-scope-request-query-parameter
147-
// Only the root block at the end of the path is returned after blocks required to verify the specified path segments.
148-
'dag-scope': 'block',
149-
protocols: retrieval.protocol,
150-
providers: retrieval.providerAddress
151-
})
152-
const url = `ipfs://${retrieval.cid}?${searchParams.toString()}`
153-
try {
154-
await this.fetchCAR(url, stats)
155-
} catch (err) {
156-
console.error(`Failed to fetch ${url}`)
157-
console.error(err)
172+
statusCode: null,
173+
indexerResult: null
158174
}
159175

176+
await this.executeRetrievalCheck(retrieval, stats)
177+
160178
const measurementId = await this.submitMeasurement(retrieval, { ...stats })
161179
Zinnia.jobCompleted()
162180
return measurementId

test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
import './test/ipni-client.test.js'
12
import './test/integration.js'
23
import './test/spark.js'

test/integration.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Spark from '../lib/spark.js'
22
import { test } from 'zinnia:test'
3-
import { assert } from 'zinnia:assert'
3+
import { assert, assertEquals } from 'zinnia:assert'
4+
5+
const KNOWN_CID = 'bafkreih25dih6ug3xtj73vswccw423b56ilrwmnos4cbwhrceudopdp5sq'
46

57
test('integration', async () => {
68
const spark = new Spark()
@@ -11,3 +13,25 @@ test('integration', async () => {
1113
assert(retrieval.startAt)
1214
assert(retrieval.finishedAt)
1315
})
16+
17+
test('retrieval check for our CID', async () => {
18+
const spark = new Spark()
19+
spark.getRetrieval = async () => ({ cid: KNOWN_CID })
20+
const measurementId = await spark.nextRetrieval()
21+
const res = await fetch(`https://api.filspark.com/measurements/${measurementId}`)
22+
assert(res.ok)
23+
const m = await res.json()
24+
const assertProp = (prop, expectedValue) => assertEquals(m[prop], expectedValue, prop)
25+
26+
assertProp('cid', KNOWN_CID)
27+
// TODO - spark-api does not record this field yet
28+
// assertProp('indexerResult', 'OK')
29+
assertProp('providerAddress', '/dns/frisbii.fly.dev/tcp/443/https')
30+
assertProp('protocol', 'http')
31+
assertProp('timeout', false)
32+
assertProp('statusCode', 200)
33+
assertProp('byteLength', 200)
34+
assertProp('carTooLarge', false)
35+
// TODO - spark-api does not record this field yet
36+
// assertProp('carChecksum', '122069f03061f7ad4c14a5691b7e96d3ddd109023a6539a0b4230ea3dc92050e7136')
37+
})

test/ipni-client.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { test } from 'zinnia:test'
2+
import { assertEquals } from 'zinnia:assert'
3+
import { queryTheIndex } from '../lib/ipni-client.js'
4+
5+
const KNOWN_CID = 'bafkreih25dih6ug3xtj73vswccw423b56ilrwmnos4cbwhrceudopdp5sq'
6+
7+
test('query advertised CID', async () => {
8+
const result = await queryTheIndex(KNOWN_CID)
9+
assertEquals(result, {
10+
indexerResult: 'OK',
11+
provider: {
12+
address: '/dns/frisbii.fly.dev/tcp/443/https',
13+
protocol: 'http'
14+
}
15+
})
16+
})

vendor/deno-deps.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// deno-fmt-ignore-file
2+
// deno-lint-ignore-file
3+
// This code was bundled using `deno bundle` and it's not recommended to edit it manually
4+
5+
const encoder = new TextEncoder();
6+
function getTypeName(value) {
7+
const type = typeof value;
8+
if (type !== "object") {
9+
return type;
10+
} else if (value === null) {
11+
return "null";
12+
} else {
13+
return value?.constructor?.name ?? "object";
14+
}
15+
}
16+
function validateBinaryLike(source) {
17+
if (typeof source === "string") {
18+
return encoder.encode(source);
19+
} else if (source instanceof Uint8Array) {
20+
return source;
21+
} else if (source instanceof ArrayBuffer) {
22+
return new Uint8Array(source);
23+
}
24+
throw new TypeError(`The input must be a Uint8Array, a string, or an ArrayBuffer. Received a value of the type ${getTypeName(source)}.`);
25+
}
26+
const hexTable = new TextEncoder().encode("0123456789abcdef");
27+
new TextEncoder();
28+
const textDecoder = new TextDecoder();
29+
function encodeHex(src) {
30+
const u8 = validateBinaryLike(src);
31+
const dst = new Uint8Array(u8.length * 2);
32+
for(let i = 0; i < dst.length; i++){
33+
const v = u8[i];
34+
dst[i * 2] = hexTable[v >> 4];
35+
dst[i * 2 + 1] = hexTable[v & 0x0f];
36+
}
37+
return textDecoder.decode(dst);
38+
}
39+
function decodeBase64(b64) {
40+
const binString = atob(b64);
41+
const size = binString.length;
42+
const bytes = new Uint8Array(size);
43+
for(let i = 0; i < size; i++){
44+
bytes[i] = binString.charCodeAt(i);
45+
}
46+
return bytes;
47+
}
48+
const MaxUInt64 = 18446744073709551615n;
49+
const REST = 0x7f;
50+
const SHIFT = 7;
51+
function decode(buf, offset = 0) {
52+
for(let i = offset, len = Math.min(buf.length, offset + 10), shift = 0, decoded = 0n; i < len; i += 1, shift += SHIFT){
53+
let __byte = buf[i];
54+
decoded += BigInt((__byte & REST) * Math.pow(2, shift));
55+
if (!(__byte & 0x80) && decoded > MaxUInt64) {
56+
throw new RangeError("overflow varint");
57+
}
58+
if (!(__byte & 0x80)) return [
59+
decoded,
60+
i + 1
61+
];
62+
}
63+
throw new RangeError("malformed or overflow varint");
64+
}
65+
export { encodeHex as encodeHex };
66+
export { decodeBase64 as decodeBase64 };
67+
export { decode as decodeVarint };

0 commit comments

Comments
 (0)