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
142 changes: 140 additions & 2 deletions yarn-project/pxe/src/logs/log_service.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
import { BlockNumber } from '@aztec/foundation/branded-types';
import { randomInt } from '@aztec/foundation/crypto/random';
import { Fr } from '@aztec/foundation/curves/bn254';
import { Point } from '@aztec/foundation/curves/grumpkin';
import { KeyStore } from '@aztec/key-store';
import { openTmpStore } from '@aztec/kv-store/lmdb-v2';
import { AztecAddress } from '@aztec/stdlib/aztec-address';
import type { L2TipsProvider } from '@aztec/stdlib/block';
import type { AztecNode } from '@aztec/stdlib/interfaces/server';
import { SiloedTag, Tag } from '@aztec/stdlib/logs';
import { makeBlockHeader, randomPrivateLogResult } from '@aztec/stdlib/testing';
import { deriveKeys } from '@aztec/stdlib/keys';
import {
AppTaggingSecret,
AppTaggingSecretKind,
type LogResult,
SiloedTag,
Tag,
appSiloEcdhSharedSecretPoint,
} from '@aztec/stdlib/logs';
import { makeBlockHeader, makeL2Tips, randomPrivateLogResult } from '@aztec/stdlib/testing';

import { type MockProxy, mock } from 'jest-mock-extended';

Expand Down Expand Up @@ -320,8 +330,136 @@ describe('LogService', () => {
});
});
});

describe('fetchTaggedLogs', () => {

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.

just noting I added related but different tests and a new helper in yarn-project/pxe/src/logs/log_service.test.ts. We could move those here or just leave the PRs as they are and handle merge conflicts once they come up

let recipient: AztecAddress;
let sharedSecret: Point;

beforeEach(async () => {
contractAddress = await AztecAddress.random();
keyStore = new KeyStore(await openTmpStore('test'));
recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test'));
taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test'));
addressStore = new AddressStore(await openTmpStore('test'));

aztecNode = mock<AztecNode>();

const l2TipsProvider = mock<L2TipsProvider>();
l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(0));

const completeAddress = await keyStore.addAccount(await deriveKeys(Fr.random()), Fr.random());
await addressStore.addCompleteAddress(completeAddress);
recipient = completeAddress.address;

sharedSecret = await Point.random();

const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) });

logService = new LogService(
aztecNode,
anchorBlockHeader,
l2TipsProvider,
keyStore,
recipientTaggingStore,
taggingSecretSourcesStore,
addressStore,
'test',
);
});

it('scans handshake secrets under the handshake derivation for both delivery modes', async () => {
await taggingSecretSourcesStore.addSharedSecret(recipient, 'handshake', sharedSecret);
const [unconstrainedTag, constrainedTag] = await handshakeTags(sharedSecret, contractAddress);

const unconstrainedLog = randomPrivateLogResult({ includeEffects: true });
const constrainedLog = randomPrivateLogResult({ includeEffects: true });
servePrivateLogsByTag(
aztecNode,
new Map([
[unconstrainedTag.toString(), unconstrainedLog],
[constrainedTag.toString(), constrainedLog],
]),
);

const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []);

const txHashes = logs.map(l => l.context.txHash);
expect(txHashes).toContainEqual(unconstrainedLog.txHash);
expect(txHashes).toContainEqual(constrainedLog.txHash);
});

it('does not scan arbitrary secrets under the handshake derivation', async () => {
await taggingSecretSourcesStore.addSharedSecret(recipient, 'arbitrary-secret', sharedSecret);
const [unconstrainedTag, constrainedTag] = await handshakeTags(sharedSecret, contractAddress);

const directionalLog = randomPrivateLogResult({ includeEffects: true });
const handshakeStreamLog = randomPrivateLogResult({ includeEffects: true });
servePrivateLogsByTag(
aztecNode,
new Map([
[(await directionalTag(sharedSecret, contractAddress, recipient)).toString(), directionalLog],
[unconstrainedTag.toString(), handshakeStreamLog],
[constrainedTag.toString(), handshakeStreamLog],
]),
);

const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []);

const txHashes = logs.map(l => l.context.txHash);
expect(txHashes).toContainEqual(directionalLog.txHash);
expect(txHashes).not.toContainEqual(handshakeStreamLog.txHash);
});

it('does not scan handshake secrets under the directional derivation', async () => {
await taggingSecretSourcesStore.addSharedSecret(recipient, 'handshake', sharedSecret);
const [unconstrainedTag] = await handshakeTags(sharedSecret, contractAddress);

const handshakeStreamLog = randomPrivateLogResult({ includeEffects: true });
const directionalLog = randomPrivateLogResult({ includeEffects: true });
servePrivateLogsByTag(
aztecNode,
new Map([
[unconstrainedTag.toString(), handshakeStreamLog],
[(await directionalTag(sharedSecret, contractAddress, recipient)).toString(), directionalLog],
]),
);

const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []);

const txHashes = logs.map(l => l.context.txHash);
expect(txHashes).toContainEqual(handshakeStreamLog.txHash);
expect(txHashes).not.toContainEqual(directionalLog.txHash);
});

async function handshakeTags(secret: Point, app: AztecAddress): Promise<SiloedTag[]> {
const appSiloedSecret = await appSiloEcdhSharedSecretPoint(secret, app);
return Promise.all(
[AppTaggingSecretKind.UNCONSTRAINED, AppTaggingSecretKind.CONSTRAINED].map(kind =>
SiloedTag.compute({ extendedSecret: new AppTaggingSecret(appSiloedSecret, app, kind), index: 0 }),

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.

looks like we can use computeAppSiloed which was added in this PR no? test is stronger as it goes through the same AppTaggingSecret API then. As otherwise I would want a test asserting that the handshake tags differs from the directional tags

),
);
}

async function directionalTag(secret: Point, app: AztecAddress, directedTo: AztecAddress): Promise<SiloedTag> {
const directionalSecret = await AppTaggingSecret.computeDirectional(secret, app, directedTo);
return SiloedTag.compute({ extendedSecret: directionalSecret, index: 0 });
}
});
});

/** Serves one private log per matching siloed tag and an empty page for every other tag. */
function servePrivateLogsByTag(aztecNode: MockProxy<AztecNode>, logsByTag: Map<string, LogResult>) {
aztecNode.getPrivateLogsByTags.mockImplementation(({ tags }) =>
Promise.resolve(
tags.map(tagQuery => {
const tag = tagQuery instanceof SiloedTag ? tagQuery : tagQuery.tag;
const log = logsByTag.get(tag.toString());
return log ? [log] : [];
}),
),
);
}

function makeLogRetrievalRequest(
contractAddress: AztecAddress,
tag: Tag,
Expand Down
33 changes: 24 additions & 9 deletions yarn-project/pxe/src/logs/log_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { CompleteAddress } from '@aztec/stdlib/contract';
import type { AztecNode } from '@aztec/stdlib/interfaces/server';
import {
AppTaggingSecret,
AppTaggingSecretKind,
type LogResult,
type PendingTaggedLog,
SiloedTag,
Expand Down Expand Up @@ -225,10 +226,12 @@ export class LogService {

/**
* Computes the tagging secrets PXE can enumerate for a recipient: one per known sender (via ECDH) plus any
* pre-shared secret points registered directly for the recipient, each siloed to `contractAddress` and directed to
* `recipient`. These require knowing the recipient's address preimage and keys, so returns an empty array when those
* are unavailable. App-supplied secrets (e.g. handshake-derived) are handled separately by the caller and do not go
* through here.
* pre-shared secrets registered directly for the recipient. Each registered secret is scanned under the tag
* streams its kind can back: sender-derived and arbitrary secrets are siloed to `contractAddress` and directed to
* `recipient`, while handshake secrets back the bare handshake tag domains (one per delivery mode), since a

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.

handshake secrets backing both handshake tag domains isn't due to "a handshake may have no announcement to discover" right? We also say "These require knowing the recipient's address preiage..." I cannot tell if "These" is referring to the sender-derived, arbitrary, or handshake secrets. This comment could use a rewording or even just a simplification. I find the code in the method to be pretty clear after just seeing "Each registered secret is scanned under the tag streams its kind can back".

* handshake may have no announcement to discover (e.g. an interactive one). These require knowing the recipient's
* address preimage and keys, so returns an empty array when those are unavailable. App-supplied secrets (e.g.
* derived from discovered handshakes) are handled separately by the caller and do not go through here.
*/
async #getPointDerivedSecrets(contractAddress: AztecAddress, recipient: AztecAddress): Promise<AppTaggingSecret[]> {
const recipientCompleteAddress = await this.addressStore.getCompleteAddress(recipient);
Expand All @@ -240,11 +243,23 @@ export class LogService {
}
const recipientIvsk = await this.keyStore.getMasterIncomingViewingSecretKey(recipient);

const points = [
...(await this.#getSecretsForSenders(recipientCompleteAddress, recipientIvsk)),
...(await this.taggingSecretSourcesStore.getSharedSecretsForRecipient(recipient)),
];
return Promise.all(points.map(secret => AppTaggingSecret.computeDirectional(secret, contractAddress, recipient)));
const [senderPoints, registeredSecrets] = await Promise.all([
this.#getSecretsForSenders(recipientCompleteAddress, recipientIvsk),
this.taggingSecretSourcesStore.getSharedSecretsForRecipient(recipient),
]);
const arbitraryPoints = registeredSecrets.filter(s => s.kind === 'arbitrary-secret').map(s => s.secret);
const handshakePoints = registeredSecrets.filter(s => s.kind === 'handshake').map(s => s.secret);

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.

nit: we could just iterate all registeredSecrets at once and use a switch statement


return Promise.all([
...[...senderPoints, ...arbitraryPoints].map(secret =>
AppTaggingSecret.computeDirectional(secret, contractAddress, recipient),
),
// A handshake-backed sender tags messages with the bare app-siloed secret, one tag domain per delivery mode.
...handshakePoints.flatMap(secret => [
AppTaggingSecret.computeAppSiloed(secret, contractAddress, AppTaggingSecretKind.UNCONSTRAINED),
AppTaggingSecret.computeAppSiloed(secret, contractAddress, AppTaggingSecretKind.CONSTRAINED),
]),
]);
}

async #getSecretsForSenders(
Expand Down
142 changes: 96 additions & 46 deletions yarn-project/pxe/src/pxe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import {
getContractClassFromArtifact,
} from '@aztec/stdlib/contract';
import type { AztecNode, AztecNodeDebug, BlockResponse } from '@aztec/stdlib/interfaces/client';
import { deriveKeys } from '@aztec/stdlib/keys';
import { computeAddressSecret, deriveKeys } from '@aztec/stdlib/keys';
import { deriveEcdhSharedSecretPoint } from '@aztec/stdlib/logs';
import {
randomContractArtifact,
randomContractInstanceWithAddress,
Expand All @@ -42,7 +43,7 @@ import { mock } from 'jest-mock-extended';
import type { MockProxy } from 'jest-mock-extended/lib/Mock.js';

import type { PXEConfig } from './config/index.js';
import { PXE, type PackedPrivateEvent, type TaggingSecretSource } from './pxe.js';
import { PXE, type PackedPrivateEvent } from './pxe.js';
import { PrivateEventStore } from './storage/private_event_store/private_event_store.js';

describe('PXE', () => {
Expand Down Expand Up @@ -153,65 +154,114 @@ describe('PXE', () => {
});

it('lists registered senders and arbitrary secrets together', async () => {
const senderSource: TaggingSecretSource = {
kind: 'address-derived',
sender: (await CompleteAddress.random()).address,
};
const secretSource: TaggingSecretSource = {
kind: 'arbitrary-secret',
recipient: (await CompleteAddress.random()).address,
secret: await Point.random(),
};

await pxe.registerTaggingSecretSource(senderSource);
await pxe.registerTaggingSecretSource(secretSource);

expect(await pxe.getTaggingSecretSources()).toEqual(expect.arrayContaining([senderSource, secretSource]));
const sender = (await CompleteAddress.random()).address;
const recipient = (await CompleteAddress.random()).address;
const secret = await Point.random();

await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender });
await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret });

expect(await pxe.getTaggingSecretSources()).toEqual(
expect.arrayContaining([
{ kind: 'address-derived', sender },
{ kind: 'arbitrary-secret', recipient, secret },
]),
);
});

it('filters tagging secret sources by kind', async () => {
const senderSource: TaggingSecretSource = {
kind: 'address-derived',
sender: (await CompleteAddress.random()).address,
};
const secretSource: TaggingSecretSource = {
kind: 'arbitrary-secret',
recipient: (await CompleteAddress.random()).address,
secret: await Point.random(),
};
const sender = (await CompleteAddress.random()).address;
const recipient = (await CompleteAddress.random()).address;
const secret = await Point.random();

await pxe.registerTaggingSecretSource(senderSource);
await pxe.registerTaggingSecretSource(secretSource);
await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender });
await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret });

const senders = await pxe.getTaggingSecretSources({ kind: 'address-derived' });
expect(senders).toContainEqual(senderSource);
expect(senders).not.toContainEqual(secretSource);
expect(senders).toContainEqual({ kind: 'address-derived', sender });
expect(senders).not.toContainEqual({ kind: 'arbitrary-secret', recipient, secret });

const secrets = await pxe.getTaggingSecretSources({ kind: 'arbitrary-secret' });
expect(secrets).toContainEqual(secretSource);
expect(secrets).not.toContainEqual(senderSource);
expect(secrets).toContainEqual({ kind: 'arbitrary-secret', recipient, secret });
expect(secrets).not.toContainEqual({ kind: 'address-derived', sender });
});

it('removes registered senders and arbitrary secrets', async () => {
const senderSource: TaggingSecretSource = {
kind: 'address-derived',
sender: (await CompleteAddress.random()).address,
};
const secretSource: TaggingSecretSource = {
kind: 'arbitrary-secret',
recipient: (await CompleteAddress.random()).address,
secret: await Point.random(),
};
const sender = (await CompleteAddress.random()).address;
const recipient = (await CompleteAddress.random()).address;
const secret = await Point.random();

await pxe.registerTaggingSecretSource(senderSource);
await pxe.registerTaggingSecretSource(secretSource);
await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender });
await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret });

await pxe.removeTaggingSecretSource(senderSource);
await pxe.removeTaggingSecretSource(secretSource);
await pxe.removeTaggingSecretSource({ kind: 'address-derived', sender });
await pxe.removeTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret });

const remaining = await pxe.getTaggingSecretSources();
expect(remaining).not.toContainEqual(senderSource);
expect(remaining).not.toContainEqual(secretSource);
expect(remaining).not.toContainEqual({ kind: 'address-derived', sender });
expect(remaining).not.toContainEqual({ kind: 'arbitrary-secret', recipient, secret });
});

it('registers a handshake by ephemeral key and lists its derived shared secret', async () => {
const privacyKeys = await deriveKeys(Fr.random());
const completeAddress = await pxe.registerAccount(privacyKeys, Fr.random());
const recipient = completeAddress.address;
const ephPk = (await Point.random()).x;

await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk });

const addressSecret = await computeAddressSecret(
await completeAddress.getPreaddress(),
privacyKeys.masterIncomingViewingSecretKey,
);
const expectedSecret = await deriveEcdhSharedSecretPoint(addressSecret, await Point.fromXAndSign(ephPk, true));
expect(await pxe.getTaggingSecretSources({ kind: 'handshake' })).toContainEqual({
kind: 'handshake',
recipient,
secret: expectedSecret,
});
});

it('ignores a handshake registered twice for the same ephemeral key', async () => {
const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random());
const ephPk = (await Point.random()).x;

await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk });
await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk });

const secrets = await pxe.getTaggingSecretSources({ kind: 'handshake' });
expect(secrets.filter(s => s.recipient.equals(recipient))).toHaveLength(1);
});

it('refuses to register a handshake for a recipient that is not an account', async () => {
const recipient = (await CompleteAddress.random()).address;
await expect(
pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk: (await Point.random()).x }),
).rejects.toThrow(/not an account/);
});

it('refuses to register a handshake whose ephemeral key x-coordinate is not on the curve', async () => {
const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random());
// x = 3 is not a valid x-coordinate on the Grumpkin curve
const ephPk = new Fr(3);
await expect(pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk })).rejects.toThrow(
`Ephemeral public key x-coordinate ${ephPk} does not correspond to a Grumpkin curve point.`,
);
});

it('removes a registered handshake by its listed secret', async () => {
const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random());
const ephPk = (await Point.random()).x;

await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk });

const [listed] = (await pxe.getTaggingSecretSources({ kind: 'handshake' })).filter(s =>
s.recipient.equals(recipient),
);
await pxe.removeTaggingSecretSource(listed);

const secrets = await pxe.getTaggingSecretSources({ kind: 'handshake' });
expect(secrets.filter(s => s.recipient.equals(recipient))).toHaveLength(0);
});

it('successfully adds a contract', async () => {
Expand Down
Loading
Loading