Skip to content
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: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [0.9.2] — 2026-05-25

### Fixed
- Replace legacy `keccak256(RLP(tx))` signing helpers with the shell-chain v0.23.0 canonical BLAKE3 preimages for standard transactions and AA bundles.
- Add `hashPaymasterTransaction()` plus Rust-compatible hash vectors for AA paymaster flows.
- Add `ShellSigner.dispose()` / `withDecryptedKeystoreSigner()` and zero decrypted secret-key buffers after keystore decryption.

### Changed
- `ShellSigner.buildSignedTransaction()` now computes the correct signing hash automatically when `txHash` is omitted.

## [0.9.1] — 2026-05-25

### Changed
Expand Down
39 changes: 16 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,11 @@

---

> **⚠️ v0.23.0 alignment status (in progress)**
> **v0.23.0 aligned**
>
> Addresses, system-contract IDs, and adapter types are aligned with shell-chain v0.23.0
> (32-byte `0x…` BLAKE3 addresses; `algo_id` byte `Dilithium3=0`, `MlDsa65=1`, `SphincsSha2256f=2`).
>
> The transaction signing-hash helpers (`hashTransaction`, `hashBatchTransaction`)
> still implement the pre-v0.23.0 `keccak256(RLP(tx))` scheme. Shell-chain v0.23.0
> nodes expect `BLAKE3(structured preimage including sig_type)` instead. A
> follow-up release will replace these helpers and regenerate the
> `tests/fixtures/rust-compatibility.json` vectors against the v0.23.0 chain.
> Do not rely on the `hashTransaction*` helpers against a v0.23.0 node yet.
> Addresses, system-contract IDs, and signing hashes now match shell-chain v0.23.0:
> 32-byte `0x…` BLAKE3 addresses, `algo_id` byte `Dilithium3=0`, `MlDsa65=1`,
> `SphincsSha2256f=2`, and BLAKE3-based transaction / AA signing hashes.


## Table of Contents
Expand Down Expand Up @@ -84,7 +78,7 @@ Send a SHELL transfer in ~10 lines:
import { MlDsa65Adapter } from "shell-sdk/adapters";
import { createShellProvider } from "shell-sdk/provider";
import { ShellSigner } from "shell-sdk/signer";
import { buildTransferTransaction, hashTransaction } from "shell-sdk/transactions";
import { buildTransferTransaction } from "shell-sdk/transactions";
import { parseEther } from "viem";

const adapter = MlDsa65Adapter.generate();
Expand All @@ -95,8 +89,7 @@ const provider = createShellProvider();
const nonce = await provider.client.getTransactionCount({ address: from });

const tx = buildTransferTransaction({ chainId: 424242, nonce, to: "0x…", value: parseEther("1") });
const txHash = hashTransaction(tx);
const signed = await signer.buildSignedTransaction({ tx, txHash });
const signed = await signer.buildSignedTransaction({ tx });
const hash = await provider.sendTransaction(signed);
console.log("tx hash:", hash);
```
Expand Down Expand Up @@ -454,18 +447,19 @@ const signed = buildSignedTransaction({

#### `hashTransaction`

RLP-encode a `ShellTransactionRequest` using the Rust node's canonical field order and return its **keccak256** hash as a `Uint8Array`. This is the value you must pass as `txHash` to `signer.buildSignedTransaction`.
Compute the canonical shell-chain v0.23.0 signing hash as **BLAKE3** over the structured preimage:

Shell Chain signs the full unsigned transaction payload in this order:
`chain_id(8B BE) || nonce(8B BE) || to(32B|zero) || value(32B BE) || data || gas_limit(8B BE) || max_fee_per_gas(8B BE) || max_priority_fee_per_gas(8B BE) || sig_type(1B) || tx_type(1B)`

`[chainId, nonce, to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, accessList, txType, blobFeeFlag, maxFeePerBlobGas, blobVersionedHashes]`
For blob transactions (`tx_type === 3`), append `max_fee_per_blob_gas(8B BE)` and each 32-byte blob hash.

```typescript
import { buildTransferTransaction, hashTransaction } from "shell-sdk/transactions";

const tx = buildTransferTransaction({ chainId: 424242, nonce: 0, to: "0x…", value: 1n });
const txHash = hashTransaction(tx); // Uint8Array (32 bytes)
const txHash = hashTransaction(tx, signer.signatureType); // Uint8Array (32 bytes)
const signed = await signer.buildSignedTransaction({ tx, txHash });
// Or simply: await signer.buildSignedTransaction({ tx })
```

---
Expand Down Expand Up @@ -594,9 +588,8 @@ const tx = buildTransferTransaction({
value: parseEther("0.5"),
});

// 5. RLP-encode and hash for signing
// (Shell uses the same EIP-1559 signing hash as Ethereum)
const txHash = hashTransaction(tx);
// 5. Compute the canonical BLAKE3 signing hash
const txHash = hashTransaction(tx, signer.signatureType);

// 6. Sign and build the complete signed transaction
// includePublicKey=true is required for accounts that haven't been seen on-chain yet
Expand Down Expand Up @@ -631,7 +624,7 @@ const tx = buildTransferTransaction({
value: parseEther("10"),
});

const txHash = hashTransaction(tx);
const txHash = hashTransaction(tx, signer.signatureType);
const signed = await signer.buildSignedTransaction({ tx, txHash });
const hash = await provider.sendTransaction(signed);
console.log(hash);
Expand Down Expand Up @@ -661,7 +654,7 @@ async function submitTransfer({ signer, to, value, rpcHttpUrl }: {
to,
value,
});
const txHash = hashTransaction(tx);
const txHash = hashTransaction(tx, signer.signatureType);
const signed = await signer.buildSignedTransaction({ tx, txHash, includePublicKey: nonce === 0 });

return provider.sendTransaction(signed);
Expand Down Expand Up @@ -717,7 +710,7 @@ const tx = buildRotateKeyTransaction({
algorithmId: newSigner.algorithmId, // 1 for MlDsa65
});

const txHash = hashTransaction(tx);
const txHash = hashTransaction(tx, currentSigner.signatureType);

// Sign with the CURRENT key
const signed = await currentSigner.buildSignedTransaction({ tx, txHash });
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shell-sdk",
"version": "0.9.1",
"version": "0.9.2",
"description": "TypeScript SDK for Shell Chain — build quantum-safe dApps before Q-Day.",
"license": "MIT",
"type": "module",
Expand Down
62 changes: 50 additions & 12 deletions src/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,16 @@ export function generateSlhDsaKeyPair(seed?: Uint8Array): SlhDsaKeyPair {
* ```
*/
export class MlDsa65Adapter implements SignerAdapter {
private readonly _publicKey: Uint8Array;
private _secretKey: Uint8Array | null;

constructor(
private readonly _publicKey: Uint8Array,
private readonly _secretKey: Uint8Array,
) {}
publicKey: Uint8Array,
secretKey: Uint8Array,
) {
this._publicKey = new Uint8Array(publicKey);
this._secretKey = new Uint8Array(secretKey);
}

/**
* Generate a fresh ML-DSA-65 key pair and wrap it in an adapter.
Expand All @@ -86,7 +92,11 @@ export class MlDsa65Adapter implements SignerAdapter {
*/
static generate(seed?: Uint8Array): MlDsa65Adapter {
const kp = generateMlDsa65KeyPair(seed);
return new MlDsa65Adapter(kp.publicKey, kp.secretKey);
try {
return new MlDsa65Adapter(kp.publicKey, kp.secretKey);
} finally {
kp.secretKey.fill(0);
}
}

/**
Expand All @@ -100,14 +110,23 @@ export class MlDsa65Adapter implements SignerAdapter {
}

/** Return the raw ML-DSA-65 public key bytes (1952 bytes). */
getPublicKey(): Uint8Array { return this._publicKey; }
getPublicKey(): Uint8Array { return new Uint8Array(this._publicKey); }

/** Zero the in-memory secret key buffer. */
dispose(): void {
this._secretKey?.fill(0);
this._secretKey = null;
}

/**
* Sign `message` with ML-DSA-65 and return the raw signature bytes.
*
* @param message - The bytes to sign (typically an RLP-encoded tx hash).
* @param message - The bytes to sign (typically a Shell signing hash).
*/
async sign(message: Uint8Array): Promise<Uint8Array> {
if (!this._secretKey) {
throw new Error("adapter secret key has been disposed");
}
return ml_dsa65.sign(message, this._secretKey);
}
}
Expand All @@ -125,10 +144,16 @@ export class MlDsa65Adapter implements SignerAdapter {
* ```
*/
export class SlhDsaAdapter implements SignerAdapter {
private readonly _publicKey: Uint8Array;
private _secretKey: Uint8Array | null;

constructor(
private readonly _publicKey: Uint8Array,
private readonly _secretKey: Uint8Array,
) {}
publicKey: Uint8Array,
secretKey: Uint8Array,
) {
this._publicKey = new Uint8Array(publicKey);
this._secretKey = new Uint8Array(secretKey);
}

/**
* Generate a fresh SLH-DSA-SHA2-256f key pair and wrap it in an adapter.
Expand All @@ -137,7 +162,11 @@ export class SlhDsaAdapter implements SignerAdapter {
*/
static generate(seed?: Uint8Array): SlhDsaAdapter {
const kp = generateSlhDsaKeyPair(seed);
return new SlhDsaAdapter(kp.publicKey, kp.secretKey);
try {
return new SlhDsaAdapter(kp.publicKey, kp.secretKey);
} finally {
kp.secretKey.fill(0);
}
}

/**
Expand All @@ -151,14 +180,23 @@ export class SlhDsaAdapter implements SignerAdapter {
}

/** Return the raw SLH-DSA public key bytes (64 bytes). */
getPublicKey(): Uint8Array { return this._publicKey; }
getPublicKey(): Uint8Array { return new Uint8Array(this._publicKey); }

/** Zero the in-memory secret key buffer. */
dispose(): void {
this._secretKey?.fill(0);
this._secretKey = null;
}

/**
* Sign `message` with SLH-DSA-SHA2-256f and return the raw signature bytes.
*
* @param message - The bytes to sign (typically an RLP-encoded tx hash).
* @param message - The bytes to sign (typically a Shell signing hash).
*/
async sign(message: Uint8Array): Promise<Uint8Array> {
if (!this._secretKey) {
throw new Error("adapter secret key has been disposed");
}
return slh_dsa_sha2_256f.sign(message, this._secretKey);
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export {
DEFAULT_TRANSFER_GAS_LIMIT,
DEFAULT_TX_TYPE,
hashBatchTransaction,
hashPaymasterTransaction,
hashTransaction,
type BuildBatchTransactionOptions,
type BuildContractPaymasterTransactionOptions,
Expand All @@ -74,6 +75,7 @@ export {
assertSignerMatchesKeystore,
decryptKeystore,
exportEncryptedKeyJson,
withDecryptedKeystoreSigner,
parseEncryptedKey,
validateEncryptedKeyAddress,
type ParsedShellKeystore,
Expand Down
48 changes: 41 additions & 7 deletions src/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,19 @@ function hexToBytes(hex: string): Uint8Array {
* @throws {Error} If the KDF or cipher is unsupported.
* @throws {Error} If decryption fails (wrong password or corrupt ciphertext).
*
* Call `signer.dispose()` as soon as you are done signing so the in-memory
* secret key copy can be zeroed. For one-shot workflows, prefer
* {@link withDecryptedKeystoreSigner} to decrypt, sign, and dispose in one pass.
*
* @example
* ```typescript
* const signer = await decryptKeystore(readFileSync("key.json", "utf8"), "my-passphrase");
* console.log(signer.getAddress()); // 0x…
* const hash = await provider.sendTransaction(await signer.buildSignedTransaction(…));
* try {
* const signedTx = await signer.buildSignedTransaction({ tx, includePublicKey: true });
* await provider.sendTransaction(signedTx);
* } finally {
* signer.dispose();
* }
* ```
*/
export async function decryptKeystore(
Expand All @@ -183,10 +191,36 @@ export async function decryptKeystore(
});
const derivedKey = hexToBytes(derivedKeyHex);

const chacha = xchacha20poly1305(derivedKey, nonce);
// Plaintext is sk-only; public key comes from the JSON `public_key` field.
const secretKey = chacha.decrypt(ciphertext);
try {
const chacha = xchacha20poly1305(derivedKey, nonce);
// Plaintext is sk-only; public key comes from the JSON `public_key` field.
const secretKey = chacha.decrypt(ciphertext);
try {
const adapter = adapterFromKeyPair(parsed.signatureType, parsed.publicKey, secretKey);
return new ShellSigner(parsed.signatureType, adapter);
} finally {
secretKey.fill(0);
}
} finally {
derivedKey.fill(0);
}
}

const adapter = adapterFromKeyPair(parsed.signatureType, parsed.publicKey, secretKey);
return new ShellSigner(parsed.signatureType, adapter);
/**
* Decrypt a keystore, run a callback, then dispose the signer in a `finally` block.
*
* This is the preferred pattern for short-lived signing operations because the
* decrypted secret key only lives for the duration of the callback.
*/
export async function withDecryptedKeystoreSigner<T>(
input: string | ShellEncryptedKey,
password: string,
fn: (signer: ShellSigner) => Promise<T> | T,
): Promise<T> {
const signer = await decryptKeystore(input, password);
try {
return await fn(signer);
} finally {
signer.dispose();
}
}
Loading
Loading