Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6be6998
Baseline super-good-enough cert
Kubuxu Nov 12, 2025
374acc3
stub decodeEndorsements
wjmelements Nov 12, 2025
5a4ec65
preferEndorsements
wjmelements Nov 12, 2025
8ce3807
start decoding endorsements
Kubuxu Nov 13, 2025
ea1c8f8
notAfter as timesetamp
wjmelements Nov 13, 2025
b2538d1
encodeEndorsements
wjmelements Nov 13, 2025
174a7e8
mv cert.ts to utils
wjmelements Nov 14, 2025
0959768
utils export from cert.ts
wjmelements Nov 14, 2025
8fdb6bf
fix test
wjmelements Nov 14, 2025
67af7c0
test that createContexts prefers endorsements
wjmelements Nov 14, 2025
a34808c
encode/decode certs with domain separator
wjmelements Nov 14, 2025
953f4c8
add cert test
wjmelements Nov 15, 2025
c4ea7c7
Merge remote-tracking branch 'origin/master' into feat/super-good-eno…
wjmelements Nov 15, 2025
e5236ad
add tools/endorse-sp.js
wjmelements Nov 15, 2025
ebebd81
increase timeout for CI
wjmelements Nov 15, 2025
344d9d2
make the decodeEndorsements a bit simpler
Kubuxu Nov 15, 2025
c81971a
add more tests
Kubuxu Nov 15, 2025
7c784b7
Update packages/synapse-core/src/utils/cert.ts
wjmelements Nov 16, 2025
cdcf6d4
rm sinon
wjmelements Nov 17, 2025
7c58dd8
Update packages/synapse-core/src/utils/cert.ts
wjmelements Nov 17, 2025
6874d4a
cleanup
wjmelements Nov 17, 2025
77a4524
Update packages/synapse-core/src/utils/cert.ts
wjmelements Nov 17, 2025
1318554
Merge remote-tracking branch 'origin/master' into feat/endorsement-ce…
wjmelements Nov 25, 2025
f1a66ef
disallow undefined chainId in decodeEndorsement
wjmelements Nov 25, 2025
72047d7
accept any of the preferred endorsements
wjmelements Nov 26, 2025
57c44a7
Merge remote-tracking branch 'origin/master' into feat/endorsement-ce…
wjmelements Jan 13, 2026
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
150 changes: 150 additions & 0 deletions packages/synapse-core/src/utils/cert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { TypedDataToPrimitiveTypes } from 'abitype'
import type { Account, Address, Chain, Client, Hex, Transport } from 'viem'
import { bytesToBigInt, bytesToHex, concat, hexToBytes, numberToHex, recoverTypedDataAddress } from 'viem'
import { signTypedData } from 'viem/actions'
import { randU256 } from '../utils/rand.ts'

export type Endorsement = {
/**
* Unique nonce to suport nonce based revocation.
*/
nonce: bigint
/**
* This certificate becomes invalid after `notAfter` timestamp.
*/
notAfter: bigint
}

export type SignedEndorsement = Endorsement & {
signature: Hex
}

export const EIP712Endorsement = {
Endorsement: [
{ name: 'nonce', type: 'uint64' },
{ name: 'notAfter', type: 'uint64' },
{ name: 'providerId', type: 'uint256' },
],
} as const

export type TypedEn = TypedDataToPrimitiveTypes<typeof EIP712Endorsement>['Endorsement']

export type SignCertOptions = {
nonce?: bigint // uint64
notAfter: bigint // uint64
providerId: bigint
} /**
* Signs a certificate that a provider is super good enough.
* @param client - The client to use to sign the message
* @param options - nonce (randomised if null), not after and who to sign it for
* @returns encoded certificate data abiEncodePacked([nonce, notAfter, signature]), the provider id is implicit by where it will get placed in registry.
*/
export async function signEndorsement(client: Client<Transport, Chain, Account>, options: SignCertOptions) {
const nonce = (options.nonce ?? randU256()) & 0xffffffffffffffffn
const signature = await signTypedData(client, {
account: client.account,
domain: {
name: 'Storage Endorsement',
Copy link
Collaborator

Choose a reason for hiding this comment

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

should be a const

version: '1',
chainId: client.chain.id,
},
types: EIP712Endorsement,
primaryType: 'Endorsement',
message: {
nonce: nonce,
notAfter: options.notAfter,
providerId: options.providerId,
},
})

// 16 because size is after hex encoding
const encodedNonce = numberToHex(nonce, { size: 8 })
const encodedNotAfter = numberToHex(options.notAfter, { size: 8 })

return concat([encodedNonce, encodedNotAfter, signature])
}

export async function decodeEndorsement(
providerId: bigint,
chainId: number | bigint | undefined,
hexData: Hex
): Promise<{
address: Address | null
endorsement: SignedEndorsement
}> {
if (hexData.length !== 164) {
return {
address: null,
endorsement: {
nonce: 0n,
notAfter: 0n,
signature: '0x',
},
}
}
Comment on lines +76 to +85
Copy link
Member

Choose a reason for hiding this comment

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

why not just throw err?

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 want to reduce use of try/catch for expected situations because it can mask unexpected situations

const data = hexToBytes(hexData)
const endorsement: SignedEndorsement = {
nonce: bytesToBigInt(data.slice(0, 8)),
notAfter: bytesToBigInt(data.slice(8, 16)),
signature: bytesToHex(data.slice(16)),
}
const address = await recoverTypedDataAddress({
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 looked into why this was async (in principle it should not be). It is only because of this async import:

export async function recoverPublicKey({
  hash,
  signature,
}: RecoverPublicKeyParameters): Promise<RecoverPublicKeyReturnType> {
  const hashHex = isHex(hash) ? hash : toHex(hash)

  const { secp256k1 } = await import('@noble/curves/secp256k1')

Copy link
Collaborator

Choose a reason for hiding this comment

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

oh, that's nasty, but oh well

domain: {
name: 'Storage Endorsement',
version: '1',
chainId,
},
types: EIP712Endorsement,
primaryType: 'Endorsement',
message: {
nonce: endorsement.nonce,
notAfter: endorsement.notAfter,
providerId: providerId,
},
signature: endorsement.signature,
}).catch((reason) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

why are we using await fn().catch(()=>return null) here? I think we can skip this entire .catch block and rely on the try/catch in decodeEndorsements no?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We want them to succeed or fail independently in case any of the capabilities aren't certs.

console.warn('Failed to recover product endorsement:', reason)
return null
})
return { address, endorsement }
}

/**
* Validates endorsement capabilities, if any, filtering out invalid ones
* @returns mapping of valid endorsements to expiry, nonce, signature
*/
export async function decodeEndorsements(
providerId: bigint,
chainId: number | bigint,
capabilities: Record<string, Hex>
): Promise<Record<Address, SignedEndorsement>> {
const now = Date.now() / 1000
return await Promise.all(
Object.values(capabilities).map((capabilityValue) => decodeEndorsement(providerId, chainId, capabilityValue))
).then((results) =>
results.reduce(
(endorsements, { address, endorsement }) => {
if (address != null && endorsement.notAfter > now) {
endorsements[address] = endorsement
}
return endorsements
},
{} as Record<Address, SignedEndorsement>
)
)
}

/**
* @returns a list of capability keys and a list of capability values for the ServiceProviderRegistry
*/
export function encodeEndorsements(endorsements: Record<Address, SignedEndorsement>): [string[], Hex[]] {
const keys: string[] = []
const values: Hex[] = []
Object.values(endorsements).forEach((value, index) => {
keys.push(`endorsement${index.toString()}`)
values.push(
concat([numberToHex(value.nonce, { size: 8 }), numberToHex(value.notAfter, { size: 8 }), value.signature])
)
})
return [keys, values]
}
1 change: 1 addition & 0 deletions packages/synapse-core/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './calibration.ts'
export * from './capabilities.ts'
export * from './cert.ts'
export * from './constants.ts'
export * from './decode-pdp-errors.ts'
export * from './format.ts'
Expand Down
14 changes: 13 additions & 1 deletion packages/synapse-core/src/utils/pdp-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Hex } from 'viem'
import { bytesToHex, hexToString, isHex, numberToBytes, stringToHex, toBytes } from 'viem'
import type { PDPOffering } from '../warm-storage/providers.ts'
import { decodeAddressCapability } from './capabilities.ts'
import { decodeEndorsements, encodeEndorsements } from './cert.ts'

// Standard capability keys for PDP product type (must match ServiceProviderRegistry.sol REQUIRED_PDP_KEYS)
export const CAP_SERVICE_URL = 'serviceURL'
Expand All @@ -18,7 +19,11 @@ export const CAP_PAYMENT_TOKEN = 'paymentTokenAddress'
* Decode PDP capabilities from keys/values arrays into a PDPOffering object.
* Based on Curio's capabilitiesToOffering function.
*/
export function decodePDPCapabilities(capabilities: Record<string, Hex>): PDPOffering {
export async function decodePDPCapabilities(
providerId: bigint,
chainId: number | bigint,
capabilities: Record<string, Hex>
): Promise<PDPOffering> {
return {
serviceURL: hexToString(capabilities.serviceURL),
minPieceSizeInBytes: BigInt(capabilities.minPieceSizeInBytes),
Expand All @@ -29,6 +34,7 @@ export function decodePDPCapabilities(capabilities: Record<string, Hex>): PDPOff
minProvingPeriodInEpochs: BigInt(capabilities.minProvingPeriodInEpochs),
location: hexToString(capabilities.location),
paymentTokenAddress: decodeAddressCapability(capabilities.paymentTokenAddress),
endorsements: await decodeEndorsements(providerId, chainId, capabilities),
}
}

Expand Down Expand Up @@ -62,6 +68,12 @@ export function encodePDPCapabilities(
capabilityKeys.push(CAP_PAYMENT_TOKEN)
capabilityValues.push(pdpOffering.paymentTokenAddress)

if (pdpOffering.endorsements != null) {
const [endorsementKeys, endorsementValues] = encodeEndorsements(pdpOffering.endorsements)
capabilityKeys.push(...endorsementKeys)
capabilityValues.push(...endorsementValues)
}

if (capabilities != null) {
for (const [key, value] of Object.entries(capabilities)) {
capabilityKeys.push(key)
Expand Down
8 changes: 6 additions & 2 deletions packages/synapse-core/src/warm-storage/data-sets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export async function getDataSets(client: Client<Transport, Chain>, options: Get
],
})
// getProviderWithProduct returns {providerId, providerInfo, product, productCapabilityValues}
const pdpCaps = decodePDPCapabilities(
const pdpCaps = await decodePDPCapabilities(
dataSet.providerId,
client.chain.id,
capabilitiesListToObject(pdpOffering.product.capabilityKeys, pdpOffering.productCapabilityValues)
)

Expand Down Expand Up @@ -178,7 +180,9 @@ export async function getDataSet(client: Client<Transport, Chain>, options: GetD
})

// getProviderWithProduct returns {providerId, providerInfo, product, productCapabilityValues}
const pdpCaps = decodePDPCapabilities(
const pdpCaps = await decodePDPCapabilities(
dataSet.providerId,
client.chain.id,
capabilitiesListToObject(pdpOffering.product.capabilityKeys, pdpOffering.productCapabilityValues)
)

Expand Down
12 changes: 9 additions & 3 deletions packages/synapse-core/src/warm-storage/providers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { AbiParametersToPrimitiveTypes, ExtractAbiFunction } from 'abitype'
import type { Chain, Client, Hex, Transport } from 'viem'
import type { Address, Chain, Client, Hex, Transport } from 'viem'
import { readContract } from 'viem/actions'
import type * as Abis from '../abis/index.ts'
import { getChain } from '../chains.ts'
import { capabilitiesListToObject } from '../utils/capabilities.ts'
import type { SignedEndorsement } from '../utils/cert.ts'
import { decodePDPCapabilities } from '../utils/pdp-capabilities.ts'

export type getProviderType = ExtractAbiFunction<typeof Abis.serviceProviderRegistry, 'getProvider'>
Expand All @@ -23,6 +24,7 @@ export interface PDPOffering {
minProvingPeriodInEpochs: bigint
location: string
paymentTokenAddress: Hex
endorsements?: Record<Address, SignedEndorsement>
}

export interface PDPProvider extends ServiceProviderInfo {
Expand Down Expand Up @@ -59,7 +61,9 @@ export async function readProviders(client: Client<Transport, Chain>): Promise<P
providers.push({
id: provider.providerId,
...provider.providerInfo,
pdp: decodePDPCapabilities(
pdp: await decodePDPCapabilities(
provider.providerId,
client.chain.id,
capabilitiesListToObject(provider.product.capabilityKeys, provider.productCapabilityValues)
),
})
Expand All @@ -83,7 +87,9 @@ export async function getProvider(client: Client<Transport, Chain>, options: Get
return {
id: provider.providerId,
...provider.providerInfo,
pdp: decodePDPCapabilities(
pdp: await decodePDPCapabilities(
provider.providerId,
client.chain.id,
capabilitiesListToObject(provider.product.capabilityKeys, provider.productCapabilityValues)
),
}
Expand Down
40 changes: 40 additions & 0 deletions packages/synapse-core/test/cert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from 'assert'

import type { Account, Chain, Client, Transport } from 'viem'
import { createWalletClient, http } from 'viem'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { calibration } from '../src/chains.ts'
import { decodeEndorsement, encodeEndorsements, signEndorsement } from '../src/utils/cert.ts'

describe('Endorsement Certificates', () => {
let client: Client<Transport, Chain, Account>
beforeEach(async () => {
client = createWalletClient({
account: privateKeyToAccount(generatePrivateKey()),
transport: http(),
chain: calibration,
})
})

it('should decode from the signed encoding the same account that signed', async () => {
const providerId = 10n
const notAfter = 0xffffffffffffffffn
const encoded = await signEndorsement(client, {
notAfter,
providerId,
})
assert.equal(encoded.length, 164)

const { address, endorsement } = await decodeEndorsement(providerId, client.chain.id, encoded)
assert.equal(address, client.account.address)
assert.equal(endorsement.notAfter, notAfter)

const [keys, values] = encodeEndorsements({
[address ?? '']: endorsement,
})
assert.equal(keys.length, values.length)
assert.equal(keys.length, 1)
assert.equal(values.length, 1)
assert.equal(values[0], encoded)
})
})
63 changes: 63 additions & 0 deletions packages/synapse-core/tools/endorse-sp.js
Copy link
Member

Choose a reason for hiding this comment

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

can you pls move this to examples/cli and make a command for this

Copy link
Contributor Author

Choose a reason for hiding this comment

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

all of those commands in cli are only using calibration. None of them consult env. How do you want those to select the chain?

Copy link
Member

Choose a reason for hiding this comment

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

ah yes will fix !

Copy link
Collaborator

Choose a reason for hiding this comment

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

@hugomrdias are we blocked on that? should we land this in place and then move it later or do we need to resolve #469 first?

Copy link
Member

@hugomrdias hugomrdias Dec 3, 2025

Choose a reason for hiding this comment

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

i just dont like random cli files in the core folder we have the utils folder for that and now the examples/cli for more structured cli/nodejs stuff.

but yes we can land this and fix later

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 don't like that the privateKeyClient/conf pattern stores the private key in a plaintext file. It would be better to use an encrypted keystore for that.

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 added support for foundry keystores: #554

Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { getChain } from '../src/chains.ts'
import { signEndorsement } from '../src/utils/cert.ts'

function printUsageAndExit() {
console.error('Usage: PRIVATE_KEY=0x... node utils/endorse-sp.js providerId...')
process.exit(1)
}

const PRIVATE_KEY = process.env.PRIVATE_KEY
const ETH_RPC_URL = process.env.ETH_RPC_URL || 'https://api.calibration.node.glif.io/rpc/v1'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

maybe these should be ??

Copy link
Collaborator

Choose a reason for hiding this comment

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

not strictly necessary but would provide clearer error cases - if you set ETH_RPC_URL to something falsy then it's going to use the default where it probably should error, e.g. "can't connect to RPC provider '0'

const EXPIRY = process.env.EXPIRY || BigInt(Math.floor(Date.now() / 1000)) + 10368000n
Copy link
Contributor Author

Choose a reason for hiding this comment

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

comment that this is (now + 4 months)


if (!PRIVATE_KEY) {
console.error('ERROR: PRIVATE_KEY environment variable is required')
printUsageAndExit()
}

let CHAIN_ID = process.env.CHAIN_ID

// TODO also support providerAddress and serviceURL
const providerIds = process.argv.slice(2)
if (providerIds.length === 0) {
console.error('ERROR: must specify at least one providerId')
printUsageAndExit()
}

async function main() {
if (CHAIN_ID == null) {
console.log('fetching eth_chainId from', ETH_RPC_URL)
const response = await fetch(ETH_RPC_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: 1,
method: 'eth_chainId',
params: [],
}),
})
const result = await response.json()
CHAIN_ID = result.result
}
console.log('ChainId:', Number(CHAIN_ID))
const client = createWalletClient({
account: privateKeyToAccount(PRIVATE_KEY),
transport: http(ETH_RPC_URL),
chain: getChain(Number(CHAIN_ID)),
})
console.log('Expiry:', new Date(Number(EXPIRY) * 1000).toDateString())
for (const providerId of providerIds) {
const encoded = await signEndorsement(client, {
providerId,
notAfter: EXPIRY,
})
console.log('Provider:', providerId)
console.log('Endorsement:', encoded)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: curio interface for hex

Copy link
Contributor Author

Choose a reason for hiding this comment

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

}
}

main().catch(console.error)
2 changes: 2 additions & 0 deletions packages/synapse-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"@types/chai": "^5.2.3",
"@types/mocha": "^10.0.10",
"@types/node": "^24.9.1",
"@types/sinon": "^20.0.0",
"@wagmi/cli": "^2.7.0",
"abitype": "^1.1.1",
"chai": "^6.2.0",
Expand All @@ -140,6 +141,7 @@
"msw": "~2.10.5",
"p-defer": "^4.0.1",
"playwright-test": "^14.1.12",
"sinon": "^21.0.0",
"type-fest": "^5.1.0",
"typescript": "5.9.3"
},
Expand Down
Loading