Skip to content
Merged
45 changes: 45 additions & 0 deletions docs/docs-developers/docs/resources/migration_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,51 @@ Aztec is in active development. Each version may introduce breaking changes that

## TBD

### Scope enforcement for private state access (TXE and PXE)

Scope enforcement is now active across both TXE (test environment) and PXE (client). Previously, private execution could implicitly access any account's keys and notes. Now, only the caller (`from`) address is in scope by default, and accessing another address's private state requires explicitly granting scope.

#### Noir developers (TXE)

TXE now enforces scope isolation, matching PXE behavior. During private execution, only the caller's keys and notes are accessible. If a Noir test accesses private state of an address other than `from`, it will fail. When `from` is the zero address, scopes are empty (deny-all).

If your TXE tests fail with key or note access errors, ensure the test is calling from the correct address, or restructure the test to match the expected access pattern.

#### Aztec.js developers (PXE/Wallet)

The wallet now passes scopes to PXE, and only the `from` address is in scope by default. Auto-expansion of scopes for nested calls to registered accounts has been removed. A new `additionalScopes` option is available on `send()`, `simulate()`, and `deploy()` for cases where private execution needs access to another address's keys or notes.

**When do you need `additionalScopes`?**

1. **Deploying contracts whose constructor initializes private storage** (e.g., account contracts, or any contract using `SinglePrivateImmutable`/`SinglePrivateMutable` in the constructor). The contract's own address must be in scope so its nullifier key is accessible during initialization.

2. **Operations that access another contract's private state** (e.g., withdrawing from an escrow contract that nullifies the contract's own token notes).

```

**Example: deploying a contract with private storage (e.g., `PrivateToken`)**

```diff
const tokenDeployment = PrivateTokenContract.deployWithPublicKeys(
tokenPublicKeys, wallet, initialBalance, sender,
);
const tokenInstance = await tokenDeployment.getInstance();
await wallet.registerContract(tokenInstance, PrivateTokenContract.artifact, tokenSecretKey);
const token = await tokenDeployment.send({
from: sender,
+ additionalScopes: [tokenInstance.address],
});
```

**Example: withdrawing from an escrow contract**

```diff
await escrowContract.methods
.withdraw(token.address, amount, recipient)
- .send({ from: owner });
+ .send({ from: owner, additionalScopes: [escrowContract.address] });
```

### `simulateUtility` renamed to `executeUtility`

The `simulateUtility` method and related types have been renamed to `executeUtility` across the entire stack to better reflect that utility functions are executed, not simulated.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,53 +299,66 @@ pub contract PendingNoteHashes {
//}

#[external("private")]
fn test_recursively_create_notes(owner: AztecAddress, how_many_recursions: u64) {
fn test_recursively_create_notes(recipients: [AztecAddress; 10], how_many_recursions: u64) {
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.

We had to make these changes when we introduced scopes, because there was no way to inject multiple scopes. Since now there is, we can revert the change we made

let initial_offset: u64 = 0;
self.internal.create_max_notes(owner, initial_offset);
self.internal.create_max_notes(recipients, initial_offset);

let max_notes = self.internal.max_notes_per_call() as u64;
self.call_self.recursively_destroy_and_create_notes(owner, how_many_recursions, max_notes);
self.call_self.recursively_destroy_and_create_notes(
recipients,
how_many_recursions,
max_notes,
);
}

#[external("private")]
fn recursively_destroy_and_create_notes(
owner: AztecAddress,
recipients: [AztecAddress; 10],
executions_left: u64,
current_offset: u64,
) {
assert(executions_left > 0);

self.internal.destroy_max_notes(owner);
self.internal.create_max_notes(owner, current_offset);
self.internal.destroy_max_notes(recipients);
self.internal.create_max_notes(recipients, current_offset);

let executions_left = executions_left - 1;

if executions_left > 0 {
let max_notes = self.internal.max_notes_per_call() as u64;
self.call_self.recursively_destroy_and_create_notes(
owner,
recipients,
executions_left,
current_offset + max_notes,
);
}
}

#[internal("private")]
fn create_max_notes(owner: AztecAddress, offset: u64) {
let owner_balance = self.storage.balances.at(owner);
fn create_max_notes(recipients: [AztecAddress; 10], offset: u64) {
// Distribute notes across recipients using global offset to ensure
// no recipient receives more than 10 notes (UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN)
for i in 0..self.internal.max_notes_per_call() {
let note = FieldNote { value: (offset + i as u64) as Field };
// Skip deliver(): notes are created and nullified in the same tx (kernel squashing),
// so tagged log delivery is unnecessary. Delivering would also exceed
// UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN for the sender-recipient pair.
let _ = owner_balance.insert(note);
let global_index = offset + i as u64;
let recipient_index = (global_index % 10) as u32;
let recipient = recipients[recipient_index];
let recipient_balance = self.storage.balances.at(recipient);

let note = FieldNote { value: i as Field };
recipient_balance.insert(note).deliver(MessageDelivery.ONCHAIN_CONSTRAINED);
}
}

#[internal("private")]
fn destroy_max_notes(owner: AztecAddress) {
let owner_balance = self.storage.balances.at(owner);
let _ = owner_balance.pop_notes(NoteGetterOptions::new());
fn destroy_max_notes(recipients: [AztecAddress; 10]) {
// Pop notes from all recipients
for i in 0..10 {
let recipient = recipients[i];
let recipient_balance = self.storage.balances.at(recipient);
// Note that we're relying on PXE actually returning the notes, we're not constraining that any specific
// number of notes are deleted.
let _ = recipient_balance.pop_notes(NoteGetterOptions::new());
}
}

#[internal("private")]
Expand Down
2 changes: 1 addition & 1 deletion yarn-project/aztec.js/src/contract/deploy_method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export type DeployOptionsWithoutWait = Omit<RequestDeployOptions, 'deployer'> &
* is mutually exclusive with "deployer"
*/
universalDeploy?: boolean;
} & Pick<SendInteractionOptionsWithoutWait, 'from' | 'fee'>;
} & Pick<SendInteractionOptionsWithoutWait, 'from' | 'fee' | 'additionalScopes'>;

/**
* Extends the deployment options with the required parameters to send the transaction.
Expand Down
7 changes: 7 additions & 0 deletions yarn-project/aztec.js/src/contract/interaction_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export type SendInteractionOptionsWithoutWait = RequestInteractionOptions & {
from: AztecAddress;
/** The fee options for the transaction. */
fee?: InteractionFeeOptions;
/**
* Additional addresses whose private state and keys should be accessible during execution,
* beyond the sender's. Required when the transaction needs to access private state or keys
* belonging to an address other than `from`, e.g. withdrawing from an escrow that holds
* its own private notes.
*/
additionalScopes?: AztecAddress[];
};

/**
Expand Down
2 changes: 2 additions & 0 deletions yarn-project/aztec.js/src/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export const SendOptionsSchema = z.object({
capsules: optional(z.array(Capsule.schema)),
fee: optional(GasSettingsOptionSchema),
wait: optional(z.union([z.literal(NO_WAIT), WaitOptsSchema])),
additionalScopes: optional(z.array(schemas.AztecAddress)),
});

export const SimulateOptionsSchema = z.object({
Expand All @@ -313,6 +314,7 @@ export const SimulateOptionsSchema = z.object({
skipTxValidation: optional(z.boolean()),
skipFeeEnforcement: optional(z.boolean()),
includeMetadata: optional(z.boolean()),
additionalScopes: optional(z.array(schemas.AztecAddress)),
});

export const ProfileOptionsSchema = SimulateOptionsSchema.extend({
Expand Down
6 changes: 5 additions & 1 deletion yarn-project/bot/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ export class BotFactory {
tokenInstance = await deploy.getInstance(deployOpts);
token = PrivateTokenContract.at(tokenInstance.address, this.wallet);
await this.wallet.registerContract(tokenInstance, PrivateTokenContract.artifact, tokenSecretKey);
// The contract constructor initializes private storage vars that need the contract's own nullifier key.
deployOpts.additionalScopes = [tokenInstance.address];
} else {
throw new Error(`Unsupported token contract type: ${this.config.contract}`);
}
Expand Down Expand Up @@ -479,8 +481,10 @@ export class BotFactory {
return;
}

// PrivateToken's mint accesses contract-level private storage vars (admin, total_supply).
const additionalScopes = isStandardToken ? undefined : [token.address];
await this.withNoMinTxsPerBlock(async () => {
const txHash = await new BatchCall(token.wallet, calls).send({ from: minter, wait: NO_WAIT });
const txHash = await new BatchCall(token.wallet, calls).send({ from: minter, additionalScopes, wait: NO_WAIT });
this.log.info(`Sent token mint tx with hash ${txHash.toString()}`);
return waitForTx(this.aztecNode, txHash, { timeout: this.config.txMinedWaitSeconds });
});
Expand Down
2 changes: 2 additions & 0 deletions yarn-project/cli-wallet/src/cmds/create_account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export async function createAccount(
skipInstancePublication: !publicDeploy,
skipInitialization,
from,
// The account constructor initializes storage vars that need the contract's own nullifier key, so we need to add it to scopes.
additionalScopes: [address],
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.

Would put here a comment regarding why we need to add the additional scope

fee: { paymentMethod, gasSettings },
};

Expand Down
2 changes: 1 addition & 1 deletion yarn-project/cli-wallet/src/utils/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class CLIWallet extends BaseWallet {
increasedFee: InteractionFeeOptions,
): Promise<TxProvingResult> {
const cancellationTxRequest = await this.createCancellationTxExecutionRequest(from, txNonce, increasedFee);
return await this.pxe.proveTx(cancellationTxRequest, this.scopesFor(from));
return await this.pxe.proveTx(cancellationTxRequest, this.scopesFrom(from));
}

override async getAccountFromAddress(address: AztecAddress) {
Expand Down
6 changes: 5 additions & 1 deletion yarn-project/end-to-end/src/composed/docs_examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ describe('docs_examples', () => {
const prefundedAccount = await wallet.createSchnorrAccount(accountData.secret, accountData.salt);
const newAccountManager = await wallet.createSchnorrAccount(secretKey, Fr.random(), signingPrivateKey);
const newAccountDeployMethod = await newAccountManager.getDeployMethod();
await newAccountDeployMethod.send({ from: prefundedAccount.address });
await newAccountDeployMethod.send({
from: prefundedAccount.address,
// The account constructor initializes storage vars that need the contract's own nullifier key, so we need to add it to scopes.
additionalScopes: [newAccountManager.address],
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.

Would put here a comment regarding why we need to add the additional scope

});
const newAccountAddress = newAccountManager.address;
const defaultAccountAddress = prefundedAccount.address;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ describe('e2e_local_network_example', () => {
return await Promise.all(
accountManagers.map(async x => {
const deployMethod = await x.getDeployMethod();
await deployMethod.send({ from: fundedAccount });
// The account constructor initializes storage vars that need the contract's own nullifier key, so we need to add it to scopes.
await deployMethod.send({ from: fundedAccount, additionalScopes: [x.address] });
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.

Would put here a comment regarding why we need to add the additional scope

return x;
}),
);
Expand Down
30 changes: 23 additions & 7 deletions yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('e2e_crowdfunding_and_claim', () => {
let crowdfundingContract: CrowdfundingContract;
let claimContract: ClaimContract;

let crowdfundingSecretKey;
let crowdfundingSecretKey: Fr;
let crowdfundingPublicKeys: PublicKeys;
let cheatCodes: CheatCodes;
let deadline: number; // end of crowdfunding period
Expand Down Expand Up @@ -94,7 +94,11 @@ describe('e2e_crowdfunding_and_claim', () => {
);
const crowdfundingInstance = await crowdfundingDeployment.getInstance();
await wallet.registerContract(crowdfundingInstance, CrowdfundingContract.artifact, crowdfundingSecretKey);
crowdfundingContract = await crowdfundingDeployment.send({ from: operatorAddress });
crowdfundingContract = await crowdfundingDeployment.send({
from: operatorAddress,
// The contract constructor initializes private storage vars that need the contract's own nullifier key.
additionalScopes: [crowdfundingInstance.address],
});
logger.info(`Crowdfunding contract deployed at ${crowdfundingContract.address}`);

claimContract = await ClaimContract.deploy(wallet, crowdfundingContract.address, rewardToken.address).send({
Expand Down Expand Up @@ -151,7 +155,10 @@ describe('e2e_crowdfunding_and_claim', () => {
expect(balanceDNTBeforeWithdrawal).toEqual(0n);

// 3) At last, we withdraw the raised funds from the crowdfunding contract to the operator's address
await crowdfundingContract.methods.withdraw(donationAmount).send({ from: operatorAddress });
await crowdfundingContract.methods
.withdraw(donationAmount)
// Withdraw nullifies the contract's own token notes, which requires its nullifier key.
.send({ from: operatorAddress, additionalScopes: [crowdfundingContract.address] });

const balanceDNTAfterWithdrawal = await donationToken.methods
.balance_of_private(operatorAddress)
Expand Down Expand Up @@ -221,7 +228,13 @@ describe('e2e_crowdfunding_and_claim', () => {
deadline,
);

otherCrowdfundingContract = await otherCrowdfundingDeployment.send({ from: operatorAddress });
const otherCrowdfundingInstance = await otherCrowdfundingDeployment.getInstance();
await wallet.registerContract(otherCrowdfundingInstance, CrowdfundingContract.artifact, crowdfundingSecretKey);
otherCrowdfundingContract = await otherCrowdfundingDeployment.send({
from: operatorAddress,
// The contract constructor initializes private storage vars that need the contract's own nullifier key.
additionalScopes: [otherCrowdfundingInstance.address],
});
logger.info(`Crowdfunding contract deployed at ${otherCrowdfundingContract.address}`);
}

Expand Down Expand Up @@ -269,9 +282,12 @@ describe('e2e_crowdfunding_and_claim', () => {
await crowdfundingContract.methods.donate(donationAmount).send({ from: donor2Address, authWitnesses: [witness] });

// The following should fail as msg_sender != operator
await expect(crowdfundingContract.methods.withdraw(donationAmount).send({ from: donor2Address })).rejects.toThrow(
'Assertion failed: Not an operator',
);
await expect(
crowdfundingContract.methods
.withdraw(donationAmount)
// Withdraw nullifies the contract's own token notes, which requires its nullifier key.
.send({ from: donor2Address, additionalScopes: [crowdfundingContract.address] }),
).rejects.toThrow('Assertion failed: Not an operator');
});

it('cannot donate after a deadline', async () => {
Expand Down
16 changes: 12 additions & 4 deletions yarn-project/end-to-end/src/e2e_escrow_contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ describe('e2e_escrow_contract', () => {
const escrowDeployment = EscrowContract.deployWithPublicKeys(escrowPublicKeys, wallet, owner);
const escrowInstance = await escrowDeployment.getInstance();
await wallet.registerContract(escrowInstance, EscrowContract.artifact, escrowSecretKey);
escrowContract = await escrowDeployment.send({ from: owner });
// The contract constructor initializes private storage vars that need the contract's own nullifier key.
escrowContract = await escrowDeployment.send({ from: owner, additionalScopes: [escrowInstance.address] });
logger.info(`Escrow contract deployed at ${escrowContract.address}`);

// Deploy Token contract and mint funds for the escrow contract
Expand All @@ -60,7 +61,10 @@ describe('e2e_escrow_contract', () => {
await expectTokenBalance(wallet, token, escrowContract.address, 100n, logger);

logger.info(`Withdrawing funds from token contract to ${recipient}`);
await escrowContract.methods.withdraw(token.address, 30, recipient).send({ from: owner });
await escrowContract.methods
.withdraw(token.address, 30, recipient)
// Withdraw nullifies the contract's own token notes, which requires its nullifier key.
.send({ from: owner, additionalScopes: [escrowContract.address] });

await expectTokenBalance(wallet, token, owner, 0n, logger);
await expectTokenBalance(wallet, token, recipient, 30n, logger);
Expand All @@ -69,7 +73,10 @@ describe('e2e_escrow_contract', () => {

it('refuses to withdraw funds as a non-owner', async () => {
await expect(
escrowContract.methods.withdraw(token.address, 30, recipient).simulate({ from: recipient }),
escrowContract.methods
.withdraw(token.address, 30, recipient)
// Withdraw nullifies the contract's own token notes, which requires its nullifier key.
.simulate({ from: recipient, additionalScopes: [escrowContract.address] }),
).rejects.toThrow();
});

Expand All @@ -84,7 +91,8 @@ describe('e2e_escrow_contract', () => {
await new BatchCall(wallet, [
token.methods.transfer(recipient, 10),
escrowContract.methods.withdraw(token.address, 20, recipient),
]).send({ from: owner });
// Withdraw nullifies the contract's own token notes, which requires its nullifier key.
]).send({ from: owner, additionalScopes: [escrowContract.address] });
await expectTokenBalance(wallet, token, recipient, 30n, logger);
});
});
15 changes: 14 additions & 1 deletion yarn-project/end-to-end/src/e2e_fees/account_init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,12 @@ describe('e2e_fees account_init', () => {
const [bobsInitialGas] = await t.getGasBalanceFn(bobsAddress);
expect(bobsInitialGas).toEqual(mintAmount);

const tx = await bobsDeployMethod.send({ from: AztecAddress.ZERO, wait: { returnReceipt: true } });
const tx = await bobsDeployMethod.send({
from: AztecAddress.ZERO,
// The account constructor initializes storage vars that need the contract's own nullifier key, so we need to add it to scopes.
additionalScopes: [bobsAddress],
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.

This is the only thing that worries me. It makes 100% total sense that account contract self-deployments need this, but for such a common operation it's getting very verbose. Maybe we should consider this a higher level abstraction and provide some sane defaults

wait: { returnReceipt: true },
});

expect(tx.transactionFee!).toBeGreaterThan(0n);
await expect(t.getGasBalanceFn(bobsAddress)).resolves.toEqual([bobsInitialGas - tx.transactionFee!]);
Expand All @@ -100,6 +105,8 @@ describe('e2e_fees account_init', () => {
const paymentMethod = new FeeJuicePaymentMethodWithClaim(bobsAddress, claim);
const tx = await bobsDeployMethod.send({
from: AztecAddress.ZERO,
// The account constructor initializes storage vars that need the contract's own nullifier key, so we need to add it to scopes.
additionalScopes: [bobsAddress],
fee: { paymentMethod },
wait: { returnReceipt: true },
});
Expand All @@ -120,6 +127,8 @@ describe('e2e_fees account_init', () => {
const paymentMethod = new PrivateFeePaymentMethod(bananaFPC.address, bobsAddress, wallet, gasSettings);
const tx = await bobsDeployMethod.send({
from: AztecAddress.ZERO,
// The account constructor initializes storage vars that need the contract's own nullifier key, so we need to add it to scopes.
additionalScopes: [bobsAddress],
fee: { paymentMethod },
wait: { returnReceipt: true },
});
Expand Down Expand Up @@ -149,6 +158,8 @@ describe('e2e_fees account_init', () => {
const paymentMethod = new PublicFeePaymentMethod(bananaFPC.address, bobsAddress, wallet, gasSettings);
const tx = await bobsDeployMethod.send({
from: AztecAddress.ZERO,
// The account constructor initializes storage vars that need the contract's own nullifier key, so we need to add it to scopes.
additionalScopes: [bobsAddress],
skipInstancePublication: false,
fee: { paymentMethod },
wait: { returnReceipt: true },
Expand Down Expand Up @@ -187,6 +198,8 @@ describe('e2e_fees account_init', () => {
bobsSigningPubKey.y,
).send({
from: aliceAddress,
// The account constructor initializes storage vars that need the contract's own nullifier key, so we need to add it to scopes.
additionalScopes: [bobsAddress],
contractAddressSalt: bobsInstance.salt,
skipClassPublication: true,
skipInstancePublication: true,
Expand Down
Loading
Loading