Skip to content

Commit

Permalink
Noble forwarding addr binary search (#215)
Browse files Browse the repository at this point in the history
  • Loading branch information
grod220 authored Oct 16, 2024
1 parent 4cdd28a commit 97fa552
Show file tree
Hide file tree
Showing 12 changed files with 1,395 additions and 630 deletions.
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());
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

0 comments on commit 97fa552

Please sign in to comment.