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
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { AztecAddress, type CompleteAddress } from '@aztec/aztec.js/addresses';
import { DomainSeparator } from '@aztec/constants';
import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto/poseidon';
import { Schnorr, type SchnorrSignature } from '@aztec/foundation/crypto/schnorr';
import { Fq, Fr } from '@aztec/foundation/curves/bn254';
import type { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin';
import type { CustomRequest } from '@aztec/pxe/config';
import {
INTERACTIVE_HANDSHAKE_REQUEST_KIND,
STANDARD_HANDSHAKE_REGISTRY_ADDRESS,
} from '@aztec/standard-contracts/handshake-registry/constants';
import { type PublicKeys, derivePublicKeyFromSecretKey } from '@aztec/stdlib/keys';

/**
* The decoded payload of the registry's interactive-handshake signature request. Note it never carries the sender,
* so the recipient authorizes the handshake without learning who initiated it.
*/
export type InteractiveHandshakeRequest = {
/** The account whose authorization is being requested. */
recipient: AztecAddress;
chainId: Fr;
version: Fr;
/** The x-coordinate of the handshake's ephemeral public key (its y-coordinate is positive by construction). */
ephPkX: Fr;
};

/**
* The recipient's signed authorization for an interactive handshake. Mirrors the registry contract's
* `RecipientSignature` struct field for field.
*/
export type RecipientSignature = {
/** The recipient's master public keys, bound in-circuit to the recipient's address via `partialAddress`. */
publicKeys: PublicKeys;
partialAddress: Fr;
/** The x-coordinate of the recipient's master message-signing public key. */
mspkX: Fr;
/** Whether that key's y-coordinate is positive, so the circuit can reconstruct the point from `mspkX`. */
mspkYIsPositive: boolean;
/** The schnorr signature over the handshake message. */
signature: SchnorrSignature;
};

/**
* Parses and validates the registry's interactive-handshake signature request.
*
* @throws If the request kind is not {@link INTERACTIVE_HANDSHAKE_REQUEST_KIND}, the issuing contract is not the
* standard HandshakeRegistry, or the payload does not have the expected shape.
*/
export function parseInteractiveHandshakeRequest(request: CustomRequest): InteractiveHandshakeRequest {
if (!request.kind.equals(INTERACTIVE_HANDSHAKE_REQUEST_KIND)) {
throw new Error(`Not an interactive-handshake signature request: unexpected kind ${request.kind}`);
}
if (!request.contractAddress.equals(STANDARD_HANDSHAKE_REGISTRY_ADDRESS)) {
throw new Error(
`Interactive-handshake signature request issued by ${request.contractAddress}, expected the standard HandshakeRegistry at ${STANDARD_HANDSHAKE_REGISTRY_ADDRESS}`,
);
}
if (request.payload.length !== 4) {
throw new Error(`Interactive-handshake signature request payload has ${request.payload.length} fields, expected 4`);
}

const [recipient, chainId, version, ephPkX] = request.payload;
return { recipient: new AztecAddress(recipient), chainId, version, ephPkX };
}

/**
* Produces the recipient's signed authorization for an interactive handshake, signing with the master
* message-signing secret key.
*/
export async function signInteractiveHandshake(
request: InteractiveHandshakeRequest,
completeAddress: CompleteAddress,
masterMessageSigningSecretKey: GrumpkinScalar,
): Promise<RecipientSignature> {
const mspk = await derivePublicKeyFromSecretKey(masterMessageSigningSecretKey);
const [mspkX, mspkYIsPositive] = mspk.toXAndSign();

const message = await computeInteractiveHandshakeSignatureMessage({
chainId: request.chainId,
version: request.version,
registry: STANDARD_HANDSHAKE_REGISTRY_ADDRESS,
ephPkX: request.ephPkX,
});
const signature = await new Schnorr().constructSignature(message, masterMessageSigningSecretKey);

return {
publicKeys: completeAddress.publicKeys,
partialAddress: completeAddress.partialAddress,
mspkX,
mspkYIsPositive,
signature,
};
}

/** Serializes a {@link RecipientSignature} to the field layout the registry's in-circuit deserialization expects. */
export function recipientSignatureToFields(recipientSignature: RecipientSignature): Fr[] {
const s = Fq.fromBuffer(recipientSignature.signature.s);
const e = Fq.fromBuffer(recipientSignature.signature.e);
return [
...recipientSignature.publicKeys.toFields(),
recipientSignature.partialAddress,
recipientSignature.mspkX,
new Fr(recipientSignature.mspkYIsPositive),
s.lo,
s.hi,
e.lo,
e.hi,
];
}

/**
* The message an interactive-handshake authorization signs: the handshake's ephemeral key and chain context under
* `DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE`, exactly as the registry recomputes it in-circuit.
*/
function computeInteractiveHandshakeSignatureMessage(args: {
chainId: Fr;
version: Fr;
registry: AztecAddress;
ephPkX: Fr;
}): Promise<Fr> {
return poseidon2HashWithSeparator(
[args.chainId, args.version, args.registry, args.ephPkX],
DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE,
);
}
57 changes: 55 additions & 2 deletions yarn-project/end-to-end/src/automine/delivery/onchain.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import type { InitialAccountData } from '@aztec/accounts/testing';
import type { CompleteAddress } from '@aztec/aztec.js/addresses';
import { Point } from '@aztec/foundation/curves/grumpkin';
import type { ResolveCustomRequest } from '@aztec/pxe/config';
import { deriveKeys } from '@aztec/stdlib/keys';

import type { TestWallet } from '../../test-wallet/test_wallet.js';
import {
parseInteractiveHandshakeRequest,
recipientSignatureToFields,
signInteractiveHandshake,
} from './interactive_handshake_responder.js';
import { buildMessageDeliveryTest } from './onchain_delivery_harness.js';

describe('onchain delivery', () => {
Expand Down Expand Up @@ -34,8 +44,8 @@ describe('onchain delivery', () => {
},
});

// With the recipient registering the sender,
// the recipient PXE reconstructs the address-derived tag and discovers the delivery.
// With the recipient registering the sender, the recipient PXE reconstructs the address-derived tag
// and discovers the delivery.
buildMessageDeliveryTest({
strategy: 'address-derived',
mode: 'unconstrained',
Expand All @@ -44,4 +54,47 @@ describe('onchain delivery', () => {
await recipientWallet.registerSender(senderAddress);
},
});

buildMessageDeliveryTest({
strategy: 'interactive handshake',
mode: 'constrained',
senderHook: () => Promise.resolve({ type: 'interactive-handshake' }),
customRequestResponder: interactiveHandshakeResponder,
});

buildMessageDeliveryTest({
strategy: 'interactive handshake',
mode: 'unconstrained',
senderHook: () => Promise.resolve({ type: 'interactive-handshake' }),
customRequestResponder: interactiveHandshakeResponder,
});

// Serves the registry's interactive-handshake signature request for the recipient: registers the handshake on the
// recipient PXE, then answers with the signed response.
function interactiveHandshakeResponder(
recipientWallet: TestWallet,
recipientAccount: InitialAccountData,
recipientCompleteAddress: CompleteAddress,
): ResolveCustomRequest {
return async request => {
const parsed = parseInteractiveHandshakeRequest(request);

// Register before signing.
await recipientWallet.registerTaggingSecretSource({
kind: 'handshake',
recipient: parsed.recipient,
ephPk: parsed.ephPkX,
});

// The master message-signing secret key is deliberately never held by PXE or the key store; the wallet
// derives it client-side from the account secret.
const { masterMessageSigningSecretKey } = await deriveKeys(recipientAccount.secret);
const recipientSignature = await signInteractiveHandshake(
parsed,
recipientCompleteAddress,
masterMessageSigningSecretKey,
);
return recipientSignatureToFields(recipientSignature);
};
}
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing';
import type { FieldLike } from '@aztec/aztec.js/abi';
import { NO_FROM } from '@aztec/aztec.js/account';
import type { AztecAddress } from '@aztec/aztec.js/addresses';
import type { AztecAddress, CompleteAddress } from '@aztec/aztec.js/addresses';
import type { AztecNode } from '@aztec/aztec.js/node';
import type { AccountManager } from '@aztec/aztec.js/wallet';
import { BlockNumber } from '@aztec/foundation/branded-types';
import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest';
import type { PXECreationOptions } from '@aztec/pxe/server';
import type { CustomRequest, ResolveCustomRequest, ResolveTaggingSecretStrategy } from '@aztec/pxe/config';
import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client';

import { jest } from '@jest/globals';
Expand All @@ -14,9 +15,13 @@ import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js';
import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from '../../fixtures/setup.js';
import { TestWallet } from '../../test-wallet/test_wallet.js';

// The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options
// rather than importing the hook type, which `@aztec/pxe/server` does not re-export.
export type SenderHook = NonNullable<NonNullable<PXECreationOptions['hooks']>['resolveTaggingSecretStrategy']>;
// Builds the hook serving custom requests issued during the sender's simulations. Installed on the sender PXE at
// creation but built only once the recipient exists, since serving typically needs the recipient's wallet and keys.
export type CustomRequestResponder = (
recipient: TestWallet,
recipientAccount: InitialAccountData,
recipientCompleteAddress: CompleteAddress,
) => ResolveCustomRequest;

export type Mode = 'constrained' | 'unconstrained';

Expand All @@ -40,19 +45,22 @@ export function buildMessageDeliveryTest(opts: {
strategy: string;
mode: DeliveryMode;
// Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests).
senderHook: SenderHook;
senderHook: ResolveTaggingSecretStrategy;
// Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment.
recipientRegistration?: (
recipient: TestWallet,
recipientAddress: AztecAddress,
sender: AztecAddress,
) => Promise<void>;
// Serves the custom requests issued during the sender's simulations (e.g. the registry's interactive-handshake
// signature request).
customRequestResponder?: CustomRequestResponder;
// Extra `it()`s to register in this cell's suite, e.g. assertions against state a custom `senderHook` recorded.
// Called inside the same `describe`, after the two baseline assertions below, so it shares their `beforeAll`
// instead of depending on Jest's cross-`describe` execution order.
additionalTests?: () => void;
}) {
const { strategy, mode, senderHook, recipientRegistration, additionalTests } = opts;
const { strategy, mode, senderHook, recipientRegistration, customRequestResponder, additionalTests } = opts;
const description = `${strategy} x ${formatMode(mode)}`;

describe(description, () => {
Expand Down Expand Up @@ -84,11 +92,14 @@ export function buildMessageDeliveryTest(opts: {
? contractSender.methods.emit_note(recipient, value)
: contractSender.methods.emit_note_unconstrained(recipient, value);

let additionallyFundedAccounts: InitialAccountData[];
let recipientAccount: AccountManager | undefined;
let customRequestCount = 0;

beforeAll(async () => {
// The sender PXE holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded
// at genesis here but created and deployed on the isolated recipient PXE below, so it carries no sender state
// from other cells.
let additionallyFundedAccounts: InitialAccountData[];
({
aztecNode,
additionallyFundedAccounts,
Expand All @@ -98,7 +109,26 @@ export function buildMessageDeliveryTest(opts: {
} = await setup(1, {
...AUTOMINE_E2E_OPTS,
additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'),
pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } },
pxeCreationOptions: {
hooks: {
resolveTaggingSecretStrategy: senderHook,
resolveCustomRequest: async (request: CustomRequest) => {
if (!customRequestResponder) {
throw new Error('A custom request arrived but this test cell has no customRequestResponder configured');
}
if (!recipientAccount) {
throw new Error('A custom request arrived before the recipient wallet was created');
}
customRequestCount++;
const respond = customRequestResponder(
walletRecipient,
additionallyFundedAccounts[0],
await recipientAccount.getCompleteAddress(),
);
return respond(request);
},
},
},
}));

({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet(
Expand All @@ -108,7 +138,7 @@ export function buildMessageDeliveryTest(opts: {
undefined,
'pxe-recipient',
));
const recipientAccount = await walletRecipient.createSchnorrAccount(
recipientAccount = await walletRecipient.createSchnorrAccount(
additionallyFundedAccounts[0].secret,
additionallyFundedAccounts[0].salt,
additionallyFundedAccounts[0].signingKey,
Expand Down Expand Up @@ -172,6 +202,12 @@ export function buildMessageDeliveryTest(opts: {
expect(readNotes).toEqual(noteValues);
});

if (customRequestResponder) {
it('the custom request hook fires exactly once, on the send that bootstraps the tagging secret', () => {
expect(customRequestCount).toBe(1);
});
}

additionalTests?.();
});
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
// Lightweight metadata leaf export for browser bundles: importing from
// `@aztec/standard-contracts/handshake-registry/constants` avoids dragging in the
// `HandshakeRegistry.json` static import.
import { sha256ToField } from '@aztec/foundation/crypto/sha256';
import type { Fr } from '@aztec/foundation/curves/bn254';
import type { AztecAddress } from '@aztec/stdlib/aztec-address';

import { StandardContractAddress, StandardContractClassId, StandardContractSalt } from '../standard_contract_data.js';

export const STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = StandardContractAddress.HandshakeRegistry;
export const STANDARD_HANDSHAKE_REGISTRY_CLASS_ID = StandardContractClassId.HandshakeRegistry;
export const STANDARD_HANDSHAKE_REGISTRY_SALT = StandardContractSalt.HandshakeRegistry;

/**
* Request kind under which the HandshakeRegistry asks for a recipient's interactive-handshake signature through the
* `resolveCustomRequest` hook. Mirrors `INTERACTIVE_HANDSHAKE_REQUEST_KIND` in the registry contract.
*/
// TODO: remove this mirrored constant and read the value from the HandshakeRegistry artifact once the contract
// global can be `#[abi]`-exported. Fixed upstream but not yet released:
// https://github.com/noir-lang/noir/pull/12714 and https://github.com/noir-lang/noir/issues/12620.
export const INTERACTIVE_HANDSHAKE_REQUEST_KIND: Fr = sha256ToField([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to use the #[abi] attribute in the contract on this global and then we can access the constant through the contract artifact. We then do not risk any constant drift.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude is telling me that #[abi] only supports literal constants. So we wouldn't be able to use sha256_to_field("HANDSHAKE_REGISTRY::INTERACTIVE_HANDSHAKE_REQUEST".as_bytes())

Do you know if it's correct?

@nchamo nchamo Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Claude is correct, then I would try to keep the current version instead of using a "magic" literal value. This test would actually catch any drifts, so I think we would be safe

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#[abi] only supports literal constants

Claude is right and it is actually worse. Confirmed with a minimal repro against the nargo we pin (beta.22). A computed initializer like sha256_to_field(...) on an #[abi] global (even 40 + 2) crashes the compiler with an internal panic during ABI emission. noir-lang/noir#12714 fixes the panic in beta.23.

Separately, exported globals are unnamed in the artifact until beta.23 (noir-lang/noir#12620).

Let's just leave a TODO marker here that we can remove this mirrored constant at the next Noir release.

Buffer.from('HANDSHAKE_REGISTRY::INTERACTIVE_HANDSHAKE_REQUEST'),
]);
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { makeStandardContract } from '../make_standard_contract.js';
import type { StandardContract } from '../standard_contract.js';

export {
INTERACTIVE_HANDSHAKE_REQUEST_KIND,
STANDARD_HANDSHAKE_REGISTRY_ADDRESS,
STANDARD_HANDSHAKE_REGISTRY_CLASS_ID,
STANDARD_HANDSHAKE_REGISTRY_SALT,
Expand Down
Loading