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
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.0] - 2026-09-28

**Breaking** — for runtime spec 16, with every compatibility shim removed:
`claimShieldedFees`, `CircuitId.ValueProof`, `RECOVERED_TX_RESULT` and
`evmAddressToAccountId` are gone, `addressToFieldElement` needs the circuit
version, and new notes are v2.

### Added

- **`memoHash(memos)`** — the `memo_hash` public input of the v2 transfer and
unshield circuits: blake2_256 over the SCALE-encoded memo list, little-endian,
reduced mod BN254 `r`. It binds the encrypted memos to the proof, so a copy of a
spend with swapped memos no longer verifies. Byte-for-byte identical to what
the chain derives (`statement::memo_digest`, reduced mod r); the test vectors
are shared with the node and `@orbinum/wallet-sdk`.
- **Public relay fee claim (runtime spec 16).** `ShieldedPoolModule.claimRelayFees`
(`claim_relay_fees`, signed, no note or proof) and, on the precompile,
`ShieldedPoolPrecompile.claimRelayFees` / `buildClaimRelayFeesCalldata` /
`estimateClaimRelayFeesGas` (`claimRelayFees(uint32,uint256)`, `0x2a3274dd`).
The pallet pays the account of the caller's registered EVM address, or the
caller when unregistered.
- **Relayer selectors and decoders:** `SP_SEL.COMMIT_RELAY` (`0xc9b235ff`) and
`SP_SEL.CLAIM_RELAY_FEES`; `decodePrecompileCalldata` names both.
- **Pool rejection codes.** `extractPoolRejection(raw)` reads `Custom error: N`
from a refused unsigned spend and `poolRejectionKind(raw)` maps it (and
`Stale` / `Payment`) to a `PalletErrorKind`; `classifyChainError` falls back to
them. New pallet names: `RelayerNotRegistered`, `NotRegistered`,
`TooManyCommits`.
- **Claim parameters are checked the same on both paths.** `claimRelayFees`
(pallet) and `buildClaimRelayFeesCalldata` (precompile) share one check:
`assetId` a u32 and `amount` in `1..2^128-1`. The pallet path used to accept an
out-of-range asset id or amount and fail only at encoding.
- **Extrinsic and event mappers follow spec 16.** `mapExtrinsicArgs` names
`private_transfer`'s `asset_id` / `fee` / `circuit_version`, `unshield`'s `fee`
/ change note / `circuit_version`, `commit_relay`, `claim_relay_fees`, and
zk-verifier `retire_version` / `unretire_version` / `purge_circuit`.
`mapZkEventData` maps `Unshielded`'s change note, `TreeSealed`,
`RelayFeesClaimed`, `VersionRetired` / `VersionUnretired`, `CircuitPurged` and
`BatchVerificationKeysRegistered`.
- **Spec-16 pallet types:** `CommitRelayArgs` / `ClaimRelayFeesArgs` in
`ShieldedPoolCall`, and `RelayFeesClaimedEvent` in `ShieldedPoolEvent`.

### Removed

- **`claimShieldedFees`** — builders (`buildClaimShieldedFeesCalldata`,
`ClaimShieldedFeesParams`), its `0x88d9deba` selector and its decoder: the call
is gone in runtime spec 16 and was never called on-chain, so
`decodePrecompileCalldata` returns null for it.
- `'shieldBatch'` from `PrecompileMethod`: the precompile has no such selector,
so it was never produced.
- **`CircuitId.ValueProof`** (6): the circuit is retired on-chain and
`@orbinum/circuits` 0.15.0 no longer ships it. `CircuitId` is now
`{ Transfer: 1, Unshield: 2 }`.
- The unexported, unused pallet-zk-verifier call and event types, which had
fallen behind the pallet (no retire / unretire / purge).
- **`ZkVerifierCircuitVersionInfo.proofSystem` / `historicalVersions`,
`ZkVerifierVkHash.stats`** and the `ZkVerifierVersionStats` /
`ZkVerifierHistoricalVersion` types: the node's `zkVerifier_*` RPC returns
none of them, and the module filled them with constants.
- **`RECOVERED_TX_RESULT`**: use `recoveredTxResult(txHash)`, which keeps the
hash.
- **`evmAddressToAccountId`** (Ethereum's prefix padding): no Orbinum account
is formed that way; `evmToImplicitSubstrate` is the runtime's mapping.
- Mapper aliases for names the chain does not use: the `deposit`, `withdraw`,
`merkleroot` and `privatetransfer` events, the `transfer` call, and alternate
field names.

### Fixed

- **`mapZkEventData` no longer maps `balances.Deposit` / `Withdraw` as shielded
events.** It keys on the method name alone, and its `deposit` / `withdraw`
aliases for `Shielded` / `Unshielded` swallowed the balances events (fee
refunds and withdrawals) with the wrong fields. `Withdraw` now reaches the
balances mapping.

### Changed

- **`CURRENT_CIRCUIT_VERSION` = 2.** New notes are created under the memo-bound
transfer / unshield circuits; v1 notes keep their stamp and spend under v2.
- **`addressToFieldElement(address, circuitVersion)`** — circuit v2 binds the
unshield recipient as `blake2_256(accountId32)` little-endian mod `r`. v1's raw
`mod r` maps an account `R` and its alias `R ± r` to the same scalar, which let
a copier redirect a pending unshield to an account nobody controls. The version
is required — the one you prove with; any version other than 1 or 2 throws
rather than guessing a rule.

## [0.5.0]

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@orbinum/protocol",
"version": "0.5.0",
"version": "0.6.0",
"description": "Official TypeScript SDK for Orbinum \u2014 chain access, encoding, note types and payment slips. Custody-free.",
"author": "Orbinum",
"license": "MIT",
Expand Down
42 changes: 14 additions & 28 deletions src/chain/evm/precompiles/ShieldedPoolPrecompile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ import type {
ShieldParams,
UnshieldParams,
PrivateTransferParams,
ClaimShieldedFeesParams,
ClaimRelayFeesParams,
} from '../../pallet/shielded-pool/extrinsicParams';
import type { EvmSigner } from './types';
import { PRECOMPILE_ADDR } from './addresses';
import {
buildShieldCalldata,
buildPrivateTransferCalldata,
buildUnshieldCalldata,
buildClaimShieldedFeesCalldata,
buildClaimRelayFeesCalldata,
} from './shieldedPoolCalldata';

export class ShieldedPoolPrecompile {
Expand All @@ -51,8 +51,8 @@ export class ShieldedPoolPrecompile {
return buildUnshieldCalldata(params);
}

buildClaimShieldedFeesCalldata(params: ClaimShieldedFeesParams): string {
return buildClaimShieldedFeesCalldata(params);
buildClaimRelayFeesCalldata(params: ClaimRelayFeesParams): string {
return buildClaimRelayFeesCalldata(params);
}

// ─── shield ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -113,30 +113,19 @@ export class ShieldedPoolPrecompile {
return signer({ to: this.addr, data: buildUnshieldCalldata(params) });
}

// ─── claimShieldedFees ───────────────────────────────────────────────────
// ─── claimRelayFees ──────────────────────────────────────────────────────

/**
* Claims accrued relay fees as a private shielded note.
* Claims the caller's pending relay fees, publicly.
*
* For validators/relayers holding fees in `pallet-relayer` who want them
* paid privately into the shielded pool rather than as a public balance
* credit. The ZK `value_proof` binds `commitment` to
* `(amount, assetId, ownerPk, blinding)`, so the runtime can verify the note
* encodes exactly the claimed amount and a malicious relayer cannot inflate
* the withdrawal.
* Spends the fees of the account registered to `msg.sender` and pays them
* to the account of that EVM address. No proof: the claim creates no note,
* and it is bounded by the claimant's own pending balance.
*
* The `msg.sender` address is the validator identity, and must match the
* one with pending fees.
*
* Extrinsic: `shieldedPool.claim_shielded_fees(commitment, amount, assetId,
* memo, proof, publicSignals, circuitVersion)` — seven arguments; see
* `buildClaimShieldedFeesCalldata`.
* Extrinsic: `shieldedPool.claim_relay_fees(asset_id, amount)`.
*/
async claimShieldedFees(params: ClaimShieldedFeesParams, signer: EvmSigner): Promise<string> {
return signer({
to: this.addr,
data: buildClaimShieldedFeesCalldata(params),
});
async claimRelayFees(params: ClaimRelayFeesParams, signer: EvmSigner): Promise<string> {
return signer({ to: this.addr, data: buildClaimRelayFeesCalldata(params) });
}

// ─── Gas estimation ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -170,14 +159,11 @@ export class ShieldedPoolPrecompile {
return this.evm.estimateGas({ from, to: this.addr, data: buildUnshieldCalldata(params) });
}

async estimateClaimShieldedFeesGas(
params: ClaimShieldedFeesParams,
from: string
): Promise<bigint> {
async estimateClaimRelayFeesGas(params: ClaimRelayFeesParams, from: string): Promise<bigint> {
return this.evm.estimateGas({
from,
to: this.addr,
data: buildClaimShieldedFeesCalldata(params),
data: buildClaimRelayFeesCalldata(params),
});
}
}
9 changes: 6 additions & 3 deletions src/chain/evm/precompiles/addresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ export const SP_SEL = {
PRIVATE_TRANSFER: new Uint8Array([0x66, 0xed, 0x2c, 0xd4]),
// unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32) → 0x4e505348
UNSHIELD: new Uint8Array([0x4e, 0x50, 0x53, 0x48]),
// claimShieldedFees(bytes32,uint256,uint32,bytes,bytes,bytes,uint32) → 0x88d9deba
CLAIM_SHIELDED_FEES: new Uint8Array([0x88, 0xd9, 0xde, 0xba]),
// commitRelay(bytes32[]) → 0xc9b235ff
COMMIT_RELAY: new Uint8Array([0xc9, 0xb2, 0x35, 0xff]),
// claimRelayFees(uint32,uint256) → 0x2a3274dd
CLAIM_RELAY_FEES: new Uint8Array([0x2a, 0x32, 0x74, 0xdd]),
} as const;

// ─── Known Precompiles registry ───────────────────────────────────────────────
Expand Down Expand Up @@ -71,7 +73,8 @@ export const KNOWN_PRECOMPILES: Record<string, KnownPrecompileInfo> = {
'privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)',
'4e505348':
'unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)',
'88d9deba': 'claimShieldedFees(bytes32,uint256,uint32,bytes,bytes,bytes,uint32)',
c9b235ff: 'commitRelay(bytes32[])',
'2a3274dd': 'claimRelayFees(uint32,uint256)',
},
},
};
Expand Down
54 changes: 31 additions & 23 deletions src/chain/evm/precompiles/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export type PrecompileMethod =
| 'shield'
| 'unshield'
| 'privateTransfer'
| 'shieldBatch'
| 'claimShieldedFees';
| 'commitRelay'
| 'claimRelayFees';

export type DecodedPrecompile = {
fnSig: string;
Expand All @@ -44,8 +44,8 @@ export type DecodedPrecompile = {
function methodOf(fnSig: string): PrecompileMethod | null {
if (fnSig.startsWith('unshield(')) return 'unshield';
if (fnSig.startsWith('privateTransfer(')) return 'privateTransfer';
if (fnSig.startsWith('shieldBatch(')) return 'shieldBatch';
if (fnSig.startsWith('claimShieldedFees(')) return 'claimShieldedFees';
if (fnSig.startsWith('commitRelay(')) return 'commitRelay';
if (fnSig.startsWith('claimRelayFees(')) return 'claimRelayFees';
if (fnSig.startsWith('shield(')) return 'shield';
return null;
}
Expand Down Expand Up @@ -131,9 +131,8 @@ export function decodePrecompileCalldata(address: string, input: string): Decode
}
}

// privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32[,bytes])
// ABI head after selector (both the legacy 8-slot and current 9-slot layout —
// every field read below sits at the same offset in both):
// privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)
// ABI head after selector (8 slots):
// [0-31] offset→proof | [32-63] root | [64-95] offset→nullifiers
// [96-127] offset→commitments | [128-159] offset→memos
// [160-191] assetId (uint32) | [192-223] fee (uint256) | [224-255] circuit_version (uint32)
Expand Down Expand Up @@ -172,27 +171,36 @@ export function decodePrecompileCalldata(address: string, input: string): Decode
}
}

// claimShieldedFees(bytes32,uint256,uint32,bytes,bytes,bytes,uint32)
// ABI head after selector (7 slots × 32 = 224 bytes):
// [0-31] commitment (bytes32)
// [32-63] amount (uint256)
// [64-95] asset_id (uint32, right-aligned)
// [96-127] offset → memo
// [128-159] offset → proof
// [160-191] offset → publicSignals
// [192-223] circuit_version (uint32, right-aligned)
if (fnSig.startsWith('claimShieldedFees(')) {
// commitRelay(bytes32[]) — head: [0-31] offset → commits; then count, items.
if (fnSig.startsWith('commitRelay(')) {
try {
const data = fromHex(input.slice(10));
if (!hasFullHead(data, 7)) return { fnSig, method: methodOf(fnSig), args: {} };
const commitment = toHex(data.slice(0, 32));
const amount = decodeUint(data, 32);
const assetId = decodeUint(data, 64);
const circuitVersion = decodeUint(data, 192);
if (!hasFullHead(data, 1)) return { fnSig, method: methodOf(fnSig), args: {} };
const offset = decodeUint(data, 0);
if (offset > BigInt(data.length - 32))
return { fnSig, method: methodOf(fnSig), args: {} };
const count = Number(decodeUint(data, Number(offset)));
const commits: string[] = [];
for (let i = 0; i < count; i++) {
const start = Number(offset) + 32 + 32 * i;
if (start + 32 > data.length) break;
commits.push(toHex(data.slice(start, start + 32)));
}
return { fnSig, method: methodOf(fnSig), args: { count, commits } };
} catch {
return { fnSig, method: methodOf(fnSig), args: {} };
}
}

// claimRelayFees(uint32,uint256) — head: [0-31] asset_id, [32-63] amount.
if (fnSig.startsWith('claimRelayFees(')) {
try {
const data = fromHex(input.slice(10));
if (!hasFullHead(data, 2)) return { fnSig, method: methodOf(fnSig), args: {} };
return {
fnSig,
method: methodOf(fnSig),
args: { commitment, amount, assetId, circuitVersion },
args: { assetId: decodeUint(data, 0), amount: decodeUint(data, 32) },
};
} catch {
return { fnSig, method: methodOf(fnSig), args: {} };
Expand Down
2 changes: 1 addition & 1 deletion src/chain/evm/precompiles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export {
buildShieldCalldata,
buildPrivateTransferCalldata,
buildUnshieldCalldata,
buildClaimShieldedFeesCalldata,
buildClaimRelayFeesCalldata,
} from './shieldedPoolCalldata';
export { CryptoPrecompiles } from './CryptoPrecompiles';
export type { EvmTxRequest, EvmSigner, KnownPrecompileInfo } from './types';
Expand Down
42 changes: 10 additions & 32 deletions src/chain/evm/precompiles/shieldedPoolCalldata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,14 @@ import type {
ShieldParams,
UnshieldParams,
PrivateTransferParams,
ClaimShieldedFeesParams,
ClaimRelayFeesParams,
} from '../../pallet/shielded-pool/extrinsicParams';
import { assertClaimRelayFeesParams } from '../../pallet/shielded-pool/validation';
import { MemoFormat } from '../../../protocol/memo/index';
import { fromHex, isHexOfLength } from '../../../foundation/encoding/hex';
import { encodeHex } from './abi';
import { SP_SEL } from './addresses';

/** Groth16 public signals for a fee claim, in bytes. */
const CLAIM_PUBLIC_SIGNALS_SIZE = 76;

// ─── Field guards ────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -176,35 +174,15 @@ export function buildUnshieldCalldata(params: UnshieldParams): string {
}

/**
* Calldata for `claimShieldedFees(bytes32,uint256,uint32,bytes,bytes,bytes,uint32)`.
*
* The validator identity is derived from `msg.sender` in the precompile, so it
* is deliberately absent from the calldata.
* Calldata for `claimRelayFees(uint32,uint256)`: pays the caller's pending relay
* fees out of the pool to the account of its EVM address. No proof — the claim
* is public and bounded by the caller's own pending balance.
*/
export function buildClaimShieldedFeesCalldata(params: ClaimShieldedFeesParams): string {
MemoFormat.validate(params.encryptedMemo, 'buildClaimShieldedFeesCalldata.encryptedMemo');

if (params.proof.length === 0) {
throw new Error('claimShieldedFees: proof must not be empty');
}
if (params.publicSignals.length !== CLAIM_PUBLIC_SIGNALS_SIZE) {
throw new Error(
`claimShieldedFees: publicSignals must be ${CLAIM_PUBLIC_SIGNALS_SIZE} bytes, got ${params.publicSignals.length}`
);
}
const commitment = bytes32(params.commitment, 'buildClaimShieldedFeesCalldata.commitment');

export function buildClaimRelayFeesCalldata(params: ClaimRelayFeesParams): string {
assertClaimRelayFeesParams(params, 'buildClaimRelayFeesCalldata');
return encodeHex(
SP_SEL.CLAIM_SHIELDED_FEES,
{ type: 'bytes32', value: commitment },
{ type: 'uint', value: params.amount },
{ type: 'uint', value: uint32(params.assetId, 'buildClaimShieldedFeesCalldata.assetId') },
{ type: 'bytes', value: params.encryptedMemo },
{ type: 'bytes', value: params.proof },
{ type: 'bytes', value: params.publicSignals },
{
type: 'uint',
value: uint32(params.circuitVersion, 'buildClaimShieldedFeesCalldata.circuitVersion'),
}
SP_SEL.CLAIM_RELAY_FEES,
{ type: 'uint', value: BigInt(params.assetId) },
{ type: 'uint', value: params.amount }
);
}
Loading
Loading