Skip to content
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 1 commit into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@
"@penumbra-zone/crypto-web": "26.0.0",
"@penumbra-zone/getters": "19.0.0",
"@penumbra-zone/keys": "4.2.1",
"@penumbra-zone/perspective": "33.0.0",
"@penumbra-zone/perspective": "34.0.0",
"@penumbra-zone/protobuf": "6.2.0",
"@penumbra-zone/query": "34.0.0",
"@penumbra-zone/services": "37.0.0",
"@penumbra-zone/storage": "33.0.0",
"@penumbra-zone/query": "35.0.0",
"@penumbra-zone/services": "38.0.0",
"@penumbra-zone/storage": "34.0.0",
"@penumbra-zone/transport-chrome": "8.0.1",
"@penumbra-zone/transport-dom": "7.5.0",
"@penumbra-zone/types": "25.0.0",
"@penumbra-zone/wasm": "30.0.0",
"@penumbra-zone/wasm": "30.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@repo/context": "workspace:*",
"@repo/ui": "workspace:*",
Expand Down
10 changes: 5 additions & 5 deletions packages/context/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@
"@penumbra-zone/crypto-web": "26.0.0",
"@penumbra-zone/getters": "19.0.0",
"@penumbra-zone/keys": "4.2.1",
"@penumbra-zone/perspective": "33.0.0",
"@penumbra-zone/perspective": "34.0.0",
"@penumbra-zone/protobuf": "6.2.0",
"@penumbra-zone/query": "34.0.0",
"@penumbra-zone/services": "37.0.0",
"@penumbra-zone/storage": "33.0.0",
"@penumbra-zone/query": "35.0.0",
"@penumbra-zone/services": "38.0.0",
"@penumbra-zone/storage": "34.0.0",
"@penumbra-zone/transport-chrome": "8.0.1",
"@penumbra-zone/transport-dom": "7.5.0",
"@penumbra-zone/types": "25.0.0",
"@penumbra-zone/wasm": "30.0.0",
"@penumbra-zone/wasm": "30.1.0",
"exponential-backoff": "^3.1.1"
}
}
13 changes: 13 additions & 0 deletions packages/noble/eslint.config.mjs
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,
},
},
});
24 changes: 24 additions & 0 deletions packages/noble/package.json
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"
}
}
103 changes: 103 additions & 0 deletions packages/noble/src/client.ts
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());
Copy link
Contributor

@TalDerei TalDerei Oct 16, 2024

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?

Copy link
Contributor Author

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.

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;
}
}
}
16 changes: 16 additions & 0 deletions packages/noble/src/error.ts
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;
};
147 changes: 147 additions & 0 deletions packages/noble/src/sequence-search.test.ts
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
});
});
Loading