-
Notifications
You must be signed in to change notification settings - Fork 23
feat: Endorsement Certificates #443
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
Changes from 15 commits
6be6998
374acc3
5a4ec65
8ce3807
ea1c8f8
b2538d1
174a7e8
0959768
8fdb6bf
67af7c0
a34808c
953f4c8
c4ea7c7
e5236ad
ebebd81
344d9d2
c81971a
7c784b7
cdcf6d4
7c58dd8
6874d4a
77a4524
1318554
f1a66ef
72047d7
57c44a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
wjmelements marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * @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', | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
wjmelements marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not just throw err?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)), | ||
wjmelements marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| const address = await recoverTypedDataAddress({ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I looked into why this was
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) => { | ||
|
||
| console.warn('Failed to recover product endorsement:', reason) | ||
wjmelements marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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] | ||
| } | ||
| 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) | ||
| }) | ||
| }) |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. all of those commands in
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah yes will fix !
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't like that the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe these should be
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not strictly necessary but would provide clearer error cases - if you set |
||
| const EXPIRY = process.env.EXPIRY || BigInt(Math.floor(Date.now() / 1000)) + 10368000n | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TODO: curio interface for hex
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
|
|
||
| main().catch(console.error) | ||
Uh oh!
There was an error while loading. Please reload this page.