-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
1,074 additions
and
215 deletions.
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": "8.0.0", | ||
"@penumbra-zone/protobuf": "6.1.0", | ||
"@penumbra-zone/wasm": "29.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,106 @@ | ||
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, | ||
); | ||
|
||
console.log({ penumbraAddr: bech32mAddress(penumbraAddr), nobleAddrBech32 }); | ||
|
||
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()); | ||
console.log(res); | ||
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.