Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
Client,
Hbar,
HbarAllowance,
KeyList,
Long,
NftId,
PublicKey,
Expand Down Expand Up @@ -939,6 +940,27 @@ export default class HederaParameterNormaliser {
};
};

/**
* Builds the `Key` for a multi-signature account.
*
* With a threshold the resulting `KeyList` is an m-of-n key: any `threshold`
* of the supplied keys can authorise a transaction. Without one every key in
* the list must sign. Returns `undefined` when no keys are supplied, so the
* caller falls through to single-key resolution.
*/
static buildMultiSigKey(publicKeys?: string[], threshold?: number): KeyList | undefined {
if (!publicKeys?.length) {
return undefined;
}
if (threshold !== undefined && (threshold < 1 || threshold > publicKeys.length)) {
throw new Error(
`Invalid threshold ${threshold}: must be between 1 and the number of public keys (${publicKeys.length}).`,
);
}
const keys = publicKeys.map(key => PublicKey.fromString(key));
return threshold === undefined ? new KeyList(keys) : new KeyList(keys, threshold);
}

static async normaliseCreateAccount(
params: z.infer<ReturnType<typeof createAccountParameters>>,
context: Context,
Expand All @@ -948,18 +970,24 @@ export default class HederaParameterNormaliser {
const parsedParams: z.infer<ReturnType<typeof createAccountParameters>> =
this.parseParamsWithSchema(params, createAccountParameters, context);

// A multi-signature account is described by publicKeys (+ optional threshold)
// and short-circuits the single-key resolution below.
const multiSigKey = this.buildMultiSigKey(parsedParams.publicKeys, parsedParams.threshold);

// Try resolving the publicKey in priority order
let publicKey = parsedParams.publicKey ?? client.operatorPublicKey?.toStringDer();
let publicKey = multiSigKey
? undefined
: (parsedParams.publicKey ?? client.operatorPublicKey?.toStringDer());

if (!publicKey) {
if (!multiSigKey && !publicKey) {
const defaultAccountId = AccountResolver.getDefaultAccount(context, client);
if (defaultAccountId) {
const account = await mirrorNode.getAccount(defaultAccountId);
publicKey = account?.accountPublicKey;
}
}

if (!publicKey) {
if (!multiSigKey && !publicKey) {
throw new Error(
'Unable to resolve public key: no param, mirror node, or client operator key available.',
);
Expand All @@ -974,7 +1002,7 @@ export default class HederaParameterNormaliser {
return {
...parsedParams,
schedulingParams,
key: PublicKey.fromString(publicKey),
key: multiSigKey ?? PublicKey.fromString(publicKey!),
};
}

Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/shared/parameter-schemas/account.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,24 @@ export const createAccountParameters = (_context: Context = {}) =>
publicKey: z
.string()
.optional()
.describe('Account public key. If not provided, the operator’s public key will be used.'),
.describe(
'Account public key for a single-signature account. If neither this nor publicKeys is provided, the operator’s public key will be used.',
),
publicKeys: z
.array(z.string())
.min(1)
.optional()
.describe(
'Public keys for a multi-signature account. Takes precedence over publicKey. Without a threshold every key must sign; with one, any `threshold` of them suffices.',
),
threshold: z
.number()
.int()
.min(1)
.optional()
.describe(
'Number of keys from publicKeys required to sign (m-of-n). Omit to require all of them. Ignored unless publicKeys is provided.',
),
accountMemo: z.string().optional().describe('Optional memo for the account.'),
initialBalance: z
.number()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Client, Key, Status } from '@hiero-ledger/sdk';
import { Client, Key, KeyList, PrivateKey, Status } from '@hiero-ledger/sdk';
import createAccountTool from '@/plugins/core-account-plugin/tools/account/create-account';
import { AgentMode, type Context } from '@/shared/configuration';
import {
Expand Down Expand Up @@ -94,6 +94,44 @@ describe('Create Account Integration Tests', () => {
expect(result.raw.status).toBe(Status.Success.toString());
expect(result.raw.scheduleId).toBeDefined();
});

it('should create a multi-signature account requiring every key', async () => {
const keys = [PrivateKey.generateED25519(), PrivateKey.generateED25519()];
const params = {
publicKeys: keys.map(key => key.publicKey.toStringDer()),
};

const tool = createAccountTool(context);
const result = await tool.execute(executorClient, context, params);

expect(result.raw.status).toBe(Status.Success.toString());
expect(result.raw.accountId).toBeDefined();

const info = await executorWrapper.getAccountInfo(result.raw.accountId!.toString());
expect(info.key).toBeInstanceOf(KeyList);
expect((info.key as KeyList).threshold).toBeNull();
});

it('should create a 2-of-3 threshold account', async () => {
const keys = [
PrivateKey.generateED25519(),
PrivateKey.generateED25519(),
PrivateKey.generateED25519(),
];
const params = {
publicKeys: keys.map(key => key.publicKey.toStringDer()),
threshold: 2,
};

const tool = createAccountTool(context);
const result = await tool.execute(executorClient, context, params);

expect(result.raw.status).toBe(Status.Success.toString());

const info = await executorWrapper.getAccountInfo(result.raw.accountId!.toString());
expect(info.key).toBeInstanceOf(KeyList);
expect((info.key as KeyList).threshold).toBe(2);
});
});

describe('Invalid Create Account Scenarios', () => {
Expand All @@ -109,6 +147,19 @@ describe('Create Account Integration Tests', () => {
expect(result.humanMessage).toMatch(/public key cannot be decoded|Invalid hex string/);
});

it('should fail when the threshold exceeds the number of public keys', async () => {
const params = {
publicKeys: [PrivateKey.generateED25519().publicKey.toStringDer()],
threshold: 2,
};

const tool = createAccountTool(context);
const result = await tool.execute(executorClient, context, params);

expect(result.raw.status).not.toBe(Status.Success.toString());
expect(result.humanMessage).toContain('Invalid threshold');
});

it('should fail with negative initial balance', async () => {
const params = {
initialBalance: -1,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PrivateKey, PublicKey } from '@hiero-ledger/sdk';
import { KeyList, PrivateKey, PublicKey } from '@hiero-ledger/sdk';
import HederaParameterNormaliser from '@/shared/hedera-utils/hedera-parameter-normaliser';

vi.mock('@/shared/utils/account-resolver', () => ({
Expand Down Expand Up @@ -166,4 +166,86 @@ describe('HederaParameterNormaliser.normaliseCreateAccount', () => {
expect(result.schedulingParams).toEqual(mockScheduleParams.schedulingParams);
expect(result.key!.toString()).toBe(params.publicKey);
});
describe('multi-signature accounts', () => {
const keyA = PrivateKey.generateED25519().publicKey.toStringDer();
const keyB = PrivateKey.generateED25519().publicKey.toStringDer();
const keyC = PrivateKey.generateED25519().publicKey.toStringDer();

it('builds a KeyList requiring every key when no threshold is given', async () => {
const result = await HederaParameterNormaliser.normaliseCreateAccount(
{ publicKeys: [keyA, keyB, keyC] } as any,
context,
client,
mirrorNode as any,
);

expect(result.key).toBeInstanceOf(KeyList);
const keyList = result.key as KeyList;
expect(keyList.threshold).toBeNull();
expect(Array.from(keyList).length).toBe(3);
});

it('builds an m-of-n threshold key when a threshold is given', async () => {
const result = await HederaParameterNormaliser.normaliseCreateAccount(
{ publicKeys: [keyA, keyB, keyC], threshold: 2 } as any,
context,
client,
mirrorNode as any,
);

expect(result.key).toBeInstanceOf(KeyList);
const keyList = result.key as KeyList;
expect(keyList.threshold).toBe(2);
expect(Array.from(keyList).length).toBe(3);
});

it('prefers publicKeys over a single publicKey', async () => {
const result = await HederaParameterNormaliser.normaliseCreateAccount(
{ publicKey: keyA, publicKeys: [keyB, keyC], threshold: 1 } as any,
context,
client,
mirrorNode as any,
);

expect(result.key).toBeInstanceOf(KeyList);
expect((result.key as KeyList).threshold).toBe(1);
});

it('does not consult the mirror node when publicKeys are supplied', async () => {
await HederaParameterNormaliser.normaliseCreateAccount(
{ publicKeys: [keyA, keyB] } as any,
context,
{ operatorPublicKey: undefined } as any,
mirrorNode as any,
);

expect(mirrorNode.getAccount).not.toHaveBeenCalled();
});

it.each([
['zero', 0],
['greater than the number of keys', 4],
])('rejects a threshold that is %s', async (_label, threshold) => {
await expect(
HederaParameterNormaliser.normaliseCreateAccount(
{ publicKeys: [keyA, keyB, keyC], threshold } as any,
context,
client,
mirrorNode as any,
),
).rejects.toThrow();
});

it('falls back to single-key resolution when publicKeys is absent', async () => {
const result = await HederaParameterNormaliser.normaliseCreateAccount(
{ publicKey: keyA } as any,
context,
client,
mirrorNode as any,
);

expect(result.key).toBeInstanceOf(PublicKey);
expect(result.key!.toString()).toBe(keyA);
});
});
});
Loading