Skip to content

Commit a30703a

Browse files
authored
feat(1861): eip-712 signing plugin (#1914)
Signed-off-by: mmyslblocky <michal.myslinski@blockydevs.com>
1 parent 0e72414 commit a30703a

41 files changed

Lines changed: 2242 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ The Hiero CLI is built on a plugin architecture. The following default plugins a
307307
- **[Swap Plugin](src/plugins/swap/README.md)** - Build and execute multi-party HBAR/token swaps in a single transaction
308308
- **[Batch Plugin](src/plugins/batch/README.md)** - Group multiple transactions into a single atomic batch transaction
309309
- **[Schedule Plugin](src/plugins/schedule/README.md)** - Create, sign, delete, and manage Hedera scheduled transactions
310+
- **[EIP-712 Plugin](src/plugins/eip712/README.md)** - Sign and verify EIP-712 structured typed data using ECDSA keys managed by the CLI KMS
310311

311312
Each plugin has its own README with detailed documentation about available commands, usage examples, and architecture details. Click on the plugin name above to learn more.
312313

docs/architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ The Hiero CLI is built on a plugin-based architecture designed to be extensible,
5555
│ ├── Contract Plugin │
5656
│ ├── Contract ERC-20 Plugin │
5757
│ ├── Contract ERC-721 Plugin │
58+
│ ├── EIP-712 Plugin │
5859
│ └── [Custom Plugins] │
5960
└─────────────────────────────────────────────────────────────┘
6061
```

docs/documentation-overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ hiero-cli/
6868
│ │ ├── token/ # Fungible and non-fungible token management plugin
6969
│ │ ├── swap/ # Multi-party HBAR/token swap plugin
7070
│ │ ├── topic/ # Topic (HCS) management plugin
71+
│ │ ├── eip712/ # EIP-712 typed data sign/verify plugin
7172
│ │ └── test/ # Test plugin (development/testing)
7273
│ └── hiero-cli.ts # Main CLI entry point
7374
├── docs/ # Technical documentation

jest.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ module.exports = {
44
},
55
testEnvironment: 'node',
66
testTimeout: 10000,
7-
testPathIgnorePatterns: ['.*/__tests__/helpers/.*'],
7+
testPathIgnorePatterns: [
8+
'.*/__tests__/.*/helpers/.*',
9+
'.*/__tests__/helpers/.*',
10+
],
811
reporters: ['default', 'jest-junit'],
912
moduleNameMapper: {
1013
'^@/(.*)$': '<rootDir>/src/$1',

skills/hiero-cli/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ State is persisted in `~/.hiero-cli/state/` as JSON files, one per plugin namesp
8282
| `credentials` | Key/credentials management | list, remove stored credentials |
8383
| `batch` | Batch transactions | create batch, add transactions, execute, list, delete |
8484
| `swap` | Multi-party asset exchange | create swap, add HBAR/FT/NFT transfers, view, list, execute, delete |
85+
| `eip712` | EIP-712 typed data signing | `hash` compute digest, `sign-ecdsa` / `sign-ed25519` sign payload (accepts pre-computed hash or domain+types+message), `verify-ecdsa` recover signer EVM address, `verify-ed25519` verify Ed25519 signature against a public key |
8586
| `plugin-management` | Plugin lifecycle | add, remove, enable, disable, list, reset, info |
8687

8788
## Agent instruction
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# eip712 plugin
2+
3+
Sign and verify EIP-712 structured typed data using ECDSA or Ed25519 keys managed by the CLI KMS. Stateless — no local state is written. Useful for permit flows, off-chain authorizations, meta-transactions, and dApp integrations on Hedera.
4+
5+
Domain, types, and message each accept either an **inline JSON string** or a **path to a JSON file**.
6+
7+
For sign/verify commands you can provide either:
8+
9+
- **`--hash`** — a pre-computed EIP-712 digest (0x-prefixed keccak256 hex), OR
10+
- **`--domain` + `--types` + `--message`** — the full typed data (all three required together)
11+
12+
These two input modes are mutually exclusive.
13+
14+
---
15+
16+
### `hcli eip712 hash`
17+
18+
Compute the EIP-712 digest (keccak256 hash) for a typed data payload without signing.
19+
20+
| Option | Short | Type | Required | Description |
21+
| ----------- | ----- | ------ | -------- | -------------------------------------------------------------- |
22+
| `--domain` | `-d` | string | **yes** | EIP-712 domain as inline JSON or path to a JSON file |
23+
| `--types` | `-t` | string | **yes** | EIP-712 types definition as inline JSON or path to a JSON file |
24+
| `--message` | `-m` | string | **yes** | Message object as inline JSON or path to a JSON file |
25+
26+
**Example:**
27+
28+
```
29+
hcli eip712 hash --domain '{"name":"MyApp","version":"1","chainId":295}' --types '{"Mail":[{"name":"from","type":"address"}]}' --message '{"from":"0xAb..."}'
30+
31+
hcli eip712 hash --domain ./domain.json --types ./types.json --message ./message.json
32+
```
33+
34+
**Output:** `{ hash }`
35+
36+
- `hash` — EIP-712 digest (0x-prefixed keccak256)
37+
38+
---
39+
40+
### `hcli eip712 sign`
41+
42+
Sign an EIP-712 typed data payload using a KMS-managed key. The algorithm (ECDSA or Ed25519) is **auto-detected from the key type** stored in the KMS.
43+
44+
| Option | Short | Type | Required | Default | Description |
45+
| --------------- | ----- | ------ | -------- | -------------- | ----------------------------------------------------------------------------------------------------------------------- |
46+
| `--key` | `-K` | string | no | operator | Signing key: `accountId:privateKey`, `ecdsa:private:{hex}`, `ed25519:private:{hex}`, key reference (`kr_xxx`), or alias |
47+
| `--key-manager` | `-k` | string | no | config default | Key manager: `local` or `local_encrypted` |
48+
| `--hash` | `-H` | string | no\* || Pre-computed EIP-712 digest (0x-prefixed hex). Mutually exclusive with typed data options |
49+
| `--domain` | `-d` | string | no\* || EIP-712 domain as inline JSON or path to a JSON file |
50+
| `--types` | `-t` | string | no\* || EIP-712 types definition as inline JSON or path to a JSON file |
51+
| `--message` | `-m` | string | no\* || Message object as inline JSON or path to a JSON file |
52+
53+
\* Provide either `--hash` OR all three of `--domain`, `--types`, `--message`.
54+
55+
**Example:**
56+
57+
```
58+
hcli eip712 sign --key my-ecdsa-key --domain ./domain.json --types ./types.json --message ./message.json
59+
60+
hcli eip712 sign --key my-ed25519-key --hash 0x<keccak256-hex>
61+
```
62+
63+
**Output (ECDSA key):** `{ signerEvm, signature, hash, r, s, v }`
64+
65+
- `signerEvm` — EVM address of the signer (checksum format)
66+
- `signature` — combined 65-byte hex string (`0x` + r + s + v)
67+
- `hash` — EIP-712 digest that was signed (0x-prefixed keccak256)
68+
- `r`, `s` — 32-byte hex components
69+
- `v` — recovery id (27 or 28)
70+
71+
**Output (Ed25519 key):** `{ signerPublicKey, hash, signature }`
72+
73+
- `signerPublicKey` — Ed25519 public key of the signer (0x-prefixed hex)
74+
- `hash` — EIP-712 digest that was signed (0x-prefixed keccak256)
75+
- `signature` — 64-byte Ed25519 signature over the digest (0x-prefixed hex)
76+
77+
---
78+
79+
### `hcli eip712 verify`
80+
81+
Verify an EIP-712 signature. The algorithm is **auto-detected from the signature length**:
82+
83+
- **65-byte signature** (0x + 130 hex chars) → ECDSA path: recovers the EVM signer address via `ecrecover`, optionally asserts it matches `--expected-signer`
84+
- **64-byte signature** (0x + 128 hex chars) → Ed25519 path: verifies the signature against a KMS-managed public key
85+
86+
Passing `--key`/`--key-manager` with a 65-byte signature, or `--expected-signer` with a 64-byte signature, is a validation error.
87+
88+
| Option | Short | Type | Required | Default | Description |
89+
| ------------------- | ----- | ------ | -------- | -------------- | ------------------------------------------------------------------------------------------ |
90+
| `--key` | `-K` | string | no | operator | Public key to verify against (Ed25519 only). Key reference, account alias, or account ID |
91+
| `--key-manager` | `-k` | string | no | config default | Key manager: `local` or `local_encrypted` (Ed25519 only) |
92+
| `--hash` | `-H` | string | no\* || Pre-computed EIP-712 digest (0x-prefixed hex). Mutually exclusive with typed data options |
93+
| `--domain` | `-d` | string | no\* || EIP-712 domain as inline JSON or path to a JSON file |
94+
| `--types` | `-t` | string | no\* || EIP-712 types definition as inline JSON or path to a JSON file |
95+
| `--message` | `-m` | string | no\* || Signed message object as inline JSON or path to a JSON file |
96+
| `--signature` | `-s` | string | **yes** || Signature to verify: 0x-prefixed 65-byte hex (ECDSA) or 64-byte hex (Ed25519) |
97+
| `--expected-signer` | `-e` | string | no || Assert recovered address matches (ECDSA only): EVM address (`0x...`), account ID, or alias |
98+
99+
\* Provide either `--hash` OR all three of `--domain`, `--types`, `--message`.
100+
101+
**Example:**
102+
103+
```
104+
# ECDSA — recover signer
105+
hcli eip712 verify --domain ./domain.json --types ./types.json --message ./message.json --signature 0x<65-byte-hex>
106+
107+
# ECDSA — assert expected signer
108+
hcli eip712 verify --hash 0x<keccak256> --signature 0x<65-byte-hex> --expected-signer 0xAbCd...1234
109+
hcli eip712 verify --hash 0x<keccak256> --signature 0x<65-byte-hex> --expected-signer 0.0.12345
110+
hcli eip712 verify --hash 0x<keccak256> --signature 0x<65-byte-hex> --expected-signer my-alias
111+
112+
# Ed25519 — verify against key
113+
hcli eip712 verify --key my-ed25519-key --domain ./domain.json --types ./types.json --message ./message.json --signature 0x<64-byte-hex>
114+
hcli eip712 verify --key my-ed25519-key --hash 0x<keccak256> --signature 0x<64-byte-hex>
115+
```
116+
117+
**Output (ECDSA):** `{ recoveredSigner, match? }`
118+
119+
- `recoveredSigner` — EVM address recovered from the signature
120+
- `match` — boolean, only present when `--expected-signer` is provided
121+
122+
**Output (Ed25519):** `{ signerPublicKey, hash, verified }`
123+
124+
- `signerPublicKey` — Ed25519 public key used for verification (raw hex)
125+
- `hash` — EIP-712 digest that was verified (0x-prefixed keccak256)
126+
- `verified` — boolean, whether the signature is valid for the given key and message
127+
128+
---
129+
130+
## Notes
131+
132+
- `--primary-type` is not a CLI option. ethers.js infers the primary type from the types definition automatically.
133+
- The plugin is stateless — no entries are written to `~/.hiero-cli/state/`.
134+
- `sign` auto-detects the algorithm from the resolved key's type in the KMS. Passing an unsupported algorithm throws a `ValidationError`.
135+
- `verify` auto-detects the algorithm from the signature length. Mixing algorithm-specific options with the wrong signature length throws a `ValidationError`.

src/core/schemas/common-schemas.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99
import type { Credential } from '@/core/services/kms/kms-types.interface';
1010

11+
import { existsSync, readFileSync } from 'fs';
1112
import { z } from 'zod';
1213

1314
import { ValidationError } from '@/core/errors';
@@ -25,6 +26,7 @@ import {
2526
} from '@/core/shared/constants';
2627
import {
2728
EntityReferenceType,
29+
JsonInputType,
2830
SupplyType,
2931
SupportedNetwork,
3032
} from '@/core/types/shared.types';
@@ -795,6 +797,82 @@ export const FilePathSchema = z
795797
.min(1, 'File path cannot be empty')
796798
.describe('Filesystem path (absolute or relative)');
797799

800+
/**
801+
* JSON Input Schema
802+
* Accepts either an inline JSON string (starts with { or [) or a filesystem path
803+
* to a JSON file. Transforms to { type, value } where value is the parsed object.
804+
*/
805+
export const JsonInputSchema = z
806+
.string()
807+
.trim()
808+
.min(1, 'JSON input cannot be empty')
809+
.transform((val): { type: JsonInputType; value: unknown } => {
810+
const trimmed = val.trim();
811+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
812+
try {
813+
return { type: JsonInputType.INLINE, value: JSON.parse(trimmed) };
814+
} catch {
815+
throw new ValidationError(
816+
`Invalid inline JSON: ${trimmed.slice(0, 80)}`,
817+
);
818+
}
819+
}
820+
if (!existsSync(trimmed)) {
821+
throw new ValidationError(`File not found: ${trimmed}`);
822+
}
823+
try {
824+
return {
825+
type: JsonInputType.FILE,
826+
value: JSON.parse(readFileSync(trimmed, 'utf-8')),
827+
};
828+
} catch {
829+
throw new ValidationError(`Could not parse JSON from file: ${trimmed}`);
830+
}
831+
})
832+
.describe('Inline JSON string or path to a JSON file');
833+
834+
/**
835+
* EIP-712 Ecdsa Signature
836+
* 0x-prefixed 65-byte ECDSA signature (r + s + v)
837+
*/
838+
export const Eip712EcdsaSignatureSchema = z
839+
.string()
840+
.regex(
841+
/^0x[0-9a-fA-F]{130}$/,
842+
'Signature must be a 0x-prefixed 65-byte hex string',
843+
)
844+
.describe('EIP-712 signature (0x-prefixed 65-byte hex)');
845+
/**
846+
* EIP-712 Signature
847+
* 0x-prefixed 65-byte ECDSA signature (r + s + v)
848+
*/
849+
export const Eip712Ed25519SignatureSchema = z
850+
.string()
851+
.regex(
852+
/^0x[0-9a-fA-F]{128}$/,
853+
'Signature must be a 0x-prefixed 64-byte hex string',
854+
)
855+
.describe('EIP-712 signature (0x-prefixed 64-byte hex)');
856+
857+
export const Eip712TypedDataFieldSchema = z.object({
858+
name: z.string(),
859+
type: z.string(),
860+
});
861+
862+
export const Eip712DomainSchema = z
863+
.object({
864+
name: z.string().optional(),
865+
version: z.string().optional(),
866+
chainId: z.union([z.number(), z.bigint()]).optional(),
867+
verifyingContract: z.string().optional(),
868+
salt: z.string().optional(),
869+
})
870+
.describe('EIP-712 domain object');
871+
872+
export const Eip712TypesSchema = z
873+
.record(z.string(), z.array(Eip712TypedDataFieldSchema))
874+
.describe('EIP-712 types definition');
875+
798876
/**
799877
* Token Name
800878
* Name of a token (alphanumeric, spaces, hyphens)

src/core/services/kms/__tests__/unit/kms-service.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ describe('KmsServiceImpl', () => {
269269
const localManager = getLocalKeyManager(KeyManager.local);
270270
const signer = {
271271
sign: jest.fn(),
272+
signWithWallet: jest.fn(),
272273
getPublicKey: jest.fn(),
273274
} as unknown as Signer;
274275
localManager.createSigner.mockReturnValue(signer);
@@ -327,6 +328,7 @@ describe('KmsServiceImpl', () => {
327328
const signerHandle = {
328329
getPublicKey: jest.fn().mockReturnValue('sign-public'),
329330
sign: jest.fn().mockResolvedValue(new Uint8Array([9])),
331+
signHashWithEcdsaKey: jest.fn(),
330332
};
331333
getLocalKeyManager(KeyManager.local).createSigner.mockReturnValue(
332334
signerHandle as Signer,
@@ -363,6 +365,7 @@ describe('KmsServiceImpl', () => {
363365
const signerHandle = {
364366
getPublicKey: jest.fn().mockReturnValue('sign-flow-public'),
365367
sign: jest.fn().mockResolvedValue(new Uint8Array([42])),
368+
signHashWithEcdsaKey: jest.fn(),
366369
};
367370
getLocalKeyManager(KeyManager.local).createSigner.mockReturnValue(
368371
signerHandle as Signer,

src/core/services/kms/signers/private-key-signer.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import type { KmsCredentialSecret } from '@/core/services/kms/kms-types.interfac
22
import type { Signer } from './signer.interface';
33

44
import { PrivateKey } from '@hiero-ledger/sdk';
5+
import { SigningKey } from 'ethers';
56

6-
import { ConfigurationError } from '@/core/errors';
7+
import { ConfigurationError, ValidationError } from '@/core/errors';
78
import { KeyAlgorithm } from '@/core/shared/constants';
89

910
/**
@@ -31,6 +32,22 @@ export class PrivateKeySigner implements Signer {
3132
return new Uint8Array(signature);
3233
}
3334

35+
signHashWithEcdsaKey(hash: string): string {
36+
if (!this.secret.privateKey) {
37+
throw new ConfigurationError('Missing private key in secret');
38+
}
39+
if (this.algorithm !== KeyAlgorithm.ECDSA) {
40+
throw new ValidationError(
41+
'Wallet signing can be only done with ECDSA key',
42+
);
43+
}
44+
const rawPrivateKey = PrivateKey.fromStringECDSA(
45+
this.secret.privateKey,
46+
).toStringRaw();
47+
const signingKey = new SigningKey(`0x${rawPrivateKey}`);
48+
return signingKey.sign(hash).serialized;
49+
}
50+
3451
getPublicKey(): string {
3552
return this.publicKey;
3653
}

src/core/services/kms/signers/signer.interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface Signer {
1515
*/
1616
sign(bytes: Uint8Array): Uint8Array;
1717

18+
signHashWithEcdsaKey(hash: string): string;
19+
1820
/**
1921
* Returns the public key associated with this signer.
2022
*

0 commit comments

Comments
 (0)