-
Notifications
You must be signed in to change notification settings - Fork 3
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
Noble sequence search #215
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { penumbraEslintConfig } from '@repo/eslint-config'; | ||
import { config, parser } from 'typescript-eslint'; | ||
|
||
export default config({ | ||
...penumbraEslintConfig, | ||
languageOptions: { | ||
parser, | ||
parserOptions: { | ||
project: true, | ||
tsconfigRootDir: import.meta.dirname, | ||
}, | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
{ | ||
"name": "@repo/noble", | ||
"version": "1.0.0", | ||
"private": true, | ||
"license": "(MIT OR Apache-2.0)", | ||
"type": "module", | ||
"scripts": { | ||
"lint": "eslint \"**/*.ts*\"", | ||
"test": "vitest run" | ||
}, | ||
"files": [ | ||
"src/", | ||
"*.md" | ||
], | ||
"exports": { | ||
".": "./src/client.ts" | ||
}, | ||
"dependencies": { | ||
"@cosmjs/stargate": "^0.32.4", | ||
"@penumbra-zone/bech32m": "9.0.0", | ||
"@penumbra-zone/protobuf": "6.2.0", | ||
"@penumbra-zone/wasm": "30.1.0" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import { FullViewingKey } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; | ||
import { MsgRegisterAccount } from '@penumbra-zone/protobuf/noble/forwarding/v1/tx_pb'; | ||
import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; | ||
import { getNobleForwardingAddr } from '@penumbra-zone/wasm/keys'; | ||
import { StargateClient } from '@cosmjs/stargate'; | ||
import { Any } from '@bufbuild/protobuf'; | ||
import { Tx } from '@penumbra-zone/protobuf/cosmos/tx/v1beta1/tx_pb'; | ||
import { SignMode } from '@penumbra-zone/protobuf/cosmos/tx/signing/v1beta1/signing_pb'; | ||
import { ForwardingPubKey } from '@penumbra-zone/protobuf/noble/forwarding/v1/account_pb'; | ||
import { CosmosSdkError, isCosmosSdkErr } from './error'; | ||
|
||
export enum NobleRegistrationResponse { | ||
// There are no funds in the account. Send funds first and request registration again. | ||
NeedsDeposit, | ||
// There were funds already deposited into the address. They have been flushed and forwarded to the sent registration address. | ||
Success, | ||
// A successful registration+flush has already occurred for this sequence number. | ||
AlreadyRegistered, | ||
} | ||
|
||
export interface NobleClientInterface { | ||
registerAccount: (props: { | ||
sequence: number; | ||
accountIndex?: number; | ||
}) => Promise<NobleRegistrationResponse>; | ||
} | ||
|
||
interface NobleClientProps { | ||
endpoint: string; | ||
channel: string; | ||
fvk: FullViewingKey; | ||
} | ||
|
||
export class NobleClient implements NobleClientInterface { | ||
private readonly channel: string; | ||
private readonly fvk: FullViewingKey; | ||
private readonly endpoint: string; | ||
|
||
constructor({ endpoint, channel, fvk }: NobleClientProps) { | ||
this.fvk = fvk; | ||
this.channel = channel; | ||
this.endpoint = endpoint; | ||
} | ||
|
||
async registerAccount({ sequence, accountIndex }: { sequence: number; accountIndex?: number }) { | ||
const { penumbraAddr, nobleAddrBech32, nobleAddrBytes } = getNobleForwardingAddr( | ||
sequence, | ||
this.fvk, | ||
this.channel, | ||
accountIndex, | ||
); | ||
|
||
const msg = new MsgRegisterAccount({ | ||
signer: nobleAddrBech32, | ||
recipient: bech32mAddress(penumbraAddr), | ||
channel: this.channel, | ||
}); | ||
|
||
const pubKey = new ForwardingPubKey({ key: nobleAddrBytes }); | ||
|
||
const tx = new Tx({ | ||
body: { | ||
messages: [ | ||
new Any({ typeUrl: '/noble.forwarding.v1.MsgRegisterAccount', value: msg.toBinary() }), | ||
], | ||
}, | ||
authInfo: { | ||
signerInfos: [ | ||
{ | ||
publicKey: new Any({ | ||
typeUrl: '/noble.forwarding.v1.ForwardingPubKey', | ||
value: pubKey.toBinary(), | ||
}), | ||
modeInfo: { sum: { case: 'single', value: { mode: SignMode.DIRECT } } }, | ||
}, | ||
], | ||
fee: { | ||
gasLimit: 200000n, | ||
}, | ||
}, | ||
signatures: [new Uint8Array()], | ||
}); | ||
|
||
const client = await StargateClient.connect(this.endpoint); | ||
|
||
try { | ||
const res = await client.broadcastTx(tx.toBinary()); | ||
if (res.code !== 0) { | ||
throw new CosmosSdkError(res.code, 'sdk', JSON.stringify(res)); | ||
} | ||
return NobleRegistrationResponse.Success; | ||
} catch (e) { | ||
if (isCosmosSdkErr(e)) { | ||
if (e.code === 9) { | ||
return NobleRegistrationResponse.NeedsDeposit; | ||
} else if (e.code === 19 || e.message.includes('tx already exists in cache')) { | ||
return NobleRegistrationResponse.AlreadyRegistered; | ||
} | ||
} | ||
throw e; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
export class CosmosSdkError extends Error { | ||
code: number; | ||
codespace: string; | ||
log: string; | ||
|
||
constructor(code: number, codespace: string, log: string) { | ||
super(log); | ||
this.code = code; | ||
this.codespace = codespace; | ||
this.log = log; | ||
} | ||
} | ||
|
||
export const isCosmosSdkErr = (e: unknown): e is CosmosSdkError => { | ||
return e !== null && typeof e === 'object' && 'code' in e; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
import { describe, expect, it } from 'vitest'; | ||
import { NobleClientInterface, NobleRegistrationResponse } from './client'; | ||
import { getNextSequence, MAX_SEQUENCE_NUMBER } from './sequence-search'; | ||
import { generateSpendKey, getFullViewingKey } from '@penumbra-zone/wasm/keys'; | ||
|
||
const seedPhrase = | ||
'benefit cherry cannon tooth exhibit law avocado spare tooth that amount pumpkin scene foil tape mobile shine apology add crouch situate sun business explain'; | ||
const spendKey = generateSpendKey(seedPhrase); | ||
const fvk = getFullViewingKey(spendKey); | ||
|
||
class MockNobleClient implements NobleClientInterface { | ||
private readonly responses = new Map<string, NobleRegistrationResponse>(); | ||
|
||
async registerAccount(props: { sequence: number; accountIndex?: number }) { | ||
const key = this.hash(props); | ||
const response = this.responses.get(key) ?? NobleRegistrationResponse.NeedsDeposit; | ||
return Promise.resolve(response); | ||
} | ||
|
||
private hash({ sequence, accountIndex }: { sequence: number; accountIndex?: number }): string { | ||
return `${sequence}-${accountIndex ? accountIndex : 0}`; | ||
} | ||
|
||
setResponse({ | ||
response, | ||
sequence, | ||
accountIndex, | ||
}: { | ||
response: NobleRegistrationResponse; | ||
sequence: number; | ||
accountIndex?: number; | ||
}) { | ||
const key = this.hash({ sequence, accountIndex }); | ||
this.responses.set(key, response); | ||
} | ||
} | ||
|
||
describe('getNextSequence', () => { | ||
it('should find the first unused sequence number when all numbers are unused', async () => { | ||
const client = new MockNobleClient(); | ||
const seq = await getNextSequence({ client, fvk }); | ||
expect(seq).toEqual(0); | ||
}); | ||
|
||
it('should find the next unused sequence number when some numbers are used', async () => { | ||
const client = new MockNobleClient(); | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: 0 }); | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: 1 }); | ||
|
||
const seq = await getNextSequence({ client, fvk }); | ||
expect(seq).toEqual(2); | ||
}); | ||
|
||
it('should return the next sequence number when the midpoint has a deposit waiting for registration', async () => { | ||
const client = new MockNobleClient(); | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: 0 }); | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: 1 }); | ||
client.setResponse({ response: NobleRegistrationResponse.Success, sequence: 2 }); | ||
|
||
const seq = await getNextSequence({ client, fvk }); | ||
expect(seq).toEqual(3); | ||
}); | ||
|
||
it('should handle the case when all sequence numbers are registered', async () => { | ||
const client = new MockNobleClient(); | ||
for (let i = 0; i <= MAX_SEQUENCE_NUMBER; i++) { | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: i }); | ||
} | ||
|
||
const seq = await getNextSequence({ client, fvk }); | ||
expect(seq).toBeGreaterThanOrEqual(0); | ||
expect(seq).toBeLessThanOrEqual(MAX_SEQUENCE_NUMBER); | ||
}); | ||
|
||
it('should handle a case deep in sequence', async () => { | ||
// Set up client so that sequences 0 to 5 are registered, and 6 onwards are unused | ||
const client = new MockNobleClient(); | ||
for (let i = 0; i <= 50_000; i++) { | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: i }); | ||
} | ||
|
||
const seq = await getNextSequence({ client, fvk }); | ||
expect(seq).toEqual(50_001); | ||
}); | ||
|
||
it('should handle entire sequence flush', async () => { | ||
const client = new MockNobleClient(); | ||
|
||
// Simulate that all sequence numbers are registered except the last one | ||
for (let i = 0; i < MAX_SEQUENCE_NUMBER; i++) { | ||
client.setResponse({ response: NobleRegistrationResponse.Success, sequence: i }); | ||
} | ||
client.setResponse({ | ||
response: NobleRegistrationResponse.Success, | ||
sequence: MAX_SEQUENCE_NUMBER, | ||
}); | ||
|
||
const seq = await getNextSequence({ client, fvk }); | ||
expect(seq).toBeGreaterThanOrEqual(0); | ||
expect(seq).toBeLessThanOrEqual(MAX_SEQUENCE_NUMBER); | ||
}); | ||
|
||
it('should handle incorrectly sequenced registrations', async () => { | ||
const client = new MockNobleClient(); | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: 0 }); | ||
client.setResponse({ response: NobleRegistrationResponse.Success, sequence: 1 }); | ||
client.setResponse({ response: NobleRegistrationResponse.NeedsDeposit, sequence: 2 }); | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: 3 }); | ||
client.setResponse({ response: NobleRegistrationResponse.Success, sequence: 4 }); | ||
client.setResponse({ response: NobleRegistrationResponse.NeedsDeposit, sequence: 5 }); | ||
|
||
const seq = await getNextSequence({ client, fvk }); | ||
|
||
// The algorithm doesn't guarantee the earliest non-deposited, but should return at least one | ||
expect([2, 5].includes(seq)).toBeTruthy(); | ||
}); | ||
|
||
it('should find the highest sequence number when only it is unused', async () => { | ||
const client = new MockNobleClient(); | ||
for (let i = 0; i < MAX_SEQUENCE_NUMBER; i++) { | ||
client.setResponse({ response: NobleRegistrationResponse.AlreadyRegistered, sequence: i }); | ||
} | ||
|
||
const seq = await getNextSequence({ client, fvk }); | ||
expect(seq).toEqual(MAX_SEQUENCE_NUMBER); | ||
}); | ||
|
||
it('should handle sequence numbers for different account indices', async () => { | ||
const client = new MockNobleClient(); | ||
client.setResponse({ | ||
response: NobleRegistrationResponse.AlreadyRegistered, | ||
sequence: 0, | ||
accountIndex: 1, | ||
}); | ||
client.setResponse({ | ||
response: NobleRegistrationResponse.NeedsDeposit, | ||
sequence: 0, | ||
accountIndex: 2, | ||
}); | ||
|
||
const seqAccount1 = await getNextSequence({ client, fvk, accountIndex: 1 }); | ||
const seqAccount2 = await getNextSequence({ client, fvk, accountIndex: 2 }); | ||
|
||
expect(seqAccount1).toEqual(1); // Next available sequence for accountIndex: 1 | ||
expect(seqAccount2).toEqual(0); // Sequence 0 is available for accountIndex: 2 | ||
}); | ||
}); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe an exponential backoff in the try / catch to avoid incomplete registrations?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The caller of this client method would be the ones responsible for re-tries I'd say. It's also easy enough for the user to re-request a noble address if an error is thrown. Can revisit this topic though as the UI comes together and we see how that would work.