diff --git a/contracts/Nargo.toml b/contracts/Nargo.toml index e7afe8c..dcda87a 100644 --- a/contracts/Nargo.toml +++ b/contracts/Nargo.toml @@ -1,4 +1,5 @@ [workspace] members = [ - "proof_of_password" + "proof_of_password", + "amm" ] \ No newline at end of file diff --git a/contracts/amm/Nargo.toml b/contracts/amm/Nargo.toml new file mode 100644 index 0000000..01e8d46 --- /dev/null +++ b/contracts/amm/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "amm_contract" +authors = [""] +type = "contract" + +[dependencies] +aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "noir-projects/aztec-nr/aztec" } +token = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "noir-projects/noir-contracts/contracts/app/token_contract" } +uint_note = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "noir-projects/aztec-nr/uint-note" } diff --git a/contracts/amm/src/config.nr b/contracts/amm/src/config.nr new file mode 100644 index 0000000..53236e6 --- /dev/null +++ b/contracts/amm/src/config.nr @@ -0,0 +1,11 @@ +use aztec::protocol::{address::AztecAddress, traits::{Deserialize, Packable, Serialize}}; +use std::meta::derive; + +/// We store the tokens of the pool in a struct such that to load it from PublicImmutable asserts only a single +/// merkle proof. +#[derive(Deserialize, Eq, Packable, Serialize)] +pub struct Config { + pub token0: AztecAddress, + pub token1: AztecAddress, + pub liquidity_token: AztecAddress, +} diff --git a/contracts/amm/src/lib.nr b/contracts/amm/src/lib.nr new file mode 100644 index 0000000..eb77f17 --- /dev/null +++ b/contracts/amm/src/lib.nr @@ -0,0 +1,96 @@ +/// Given an input amount of an asset and pair balances, returns the maximum output amount of the other asset. +pub fn get_amount_out(amount_in: u128, balance_in: u128, balance_out: u128) -> u128 { + assert(amount_in > 0 as u128, "INSUFFICIENT_INPUT_AMOUNT"); + assert((balance_in > 0 as u128) & (balance_out > 0 as u128), "INSUFFICIENT_LIQUIDITY"); + + // The expression below is: + // (amount_in * 997 * balance_out) / (balance_in * 10000 + amount_in * 997) + // which is equivalent to: + // balance_out * ((amount_in * 0.997) / (balance_in + amount_in * 0.997)) + // resulting in an implicit 0.3% fee on the amount in, as the fee tokens are not taken into consideration. + + let amount_in_with_fee = amount_in * 997 as u128; + let numerator = amount_in_with_fee * balance_out; + let denominator = balance_in * 1000 as u128 + amount_in_with_fee; + numerator / denominator +} + +/// Given an output amount of an asset and pair balances, returns a required input amount of the other asset. +pub fn get_amount_in(amount_out: u128, balance_in: u128, balance_out: u128) -> u128 { + assert(amount_out > 0 as u128, "INSUFFICIENT_OUTPUT_AMOUNT"); + assert((balance_in > 0 as u128) & (balance_out > 0 as u128), "INSUFFICIENT_LIQUIDITY"); + + // The expression below is: + // (balance_in * amount_out * 1000) / (balance_out - amount_out * 997) + 1 + // which is equivalent to: + // balance_in * (amount_out / (balance_in + amount_in)) * 1/0.997 + 1 + // resulting in an implicit 0.3% fee on the amount in, as the fee tokens are not taken into consideration. The +1 + // at the end ensures the rounding error favors the pool. + + let numerator = balance_in * amount_out * 1000 as u128; + let denominator = (balance_out - amount_out) * 997 as u128; + (numerator / denominator) + 1 as u128 +} + +/// Given the desired amounts and balances of token0 and token1 returns the optimal amount of token0 and token1 to be added to the pool. +pub fn get_amounts_to_add( + amount0_max: u128, + amount1_max: u128, + amount0_min: u128, + amount1_min: u128, + balance0: u128, + balance1: u128, +) -> (u128, u128) { + // When adding tokens, both balances must grow by the same ratio, which means that their spot price is unchanged. + // Since any swaps would affect these ratios, liquidity providers supply a range of minimum and maximum balances + // they are willing to supply for each token (which translates to minimum and maximum relative prices of the + // tokens, preventing loss of value outside of this range due to e.g. front-running). + + if (balance0 == 0 as u128) | (balance1 == 0 as u128) { + // The token balances should only be zero when initializing the pool. In this scenario there is no prior ratio + // to follow so we simply transfer the full maximum balance - it is up to the caller to make sure that the ratio + // they've chosen results in a a reasonable spot price. + (amount0_max, amount1_max) + } else { + // There is a huge number of amount combinations that respect the minimum and maximum for each token, but we'll + // only consider the two scenarios in which one of the amounts is the maximum amount. + + // First we calculate the token1 amount that'd need to be supplied if we used the maximum amount for token0. + let amount1_equivalent = get_equivalent_amount(amount0_max, balance0, balance1); + if (amount1_equivalent <= amount1_max) { + assert(amount1_equivalent >= amount1_min, "AMOUNT_1_BELOW_MINIMUM"); + (amount0_max, amount1_equivalent) + } else { + // If the max amount for token0 results in a token1 amount larger than the maximum, then we try with the + // maximum token1 amount, hoping that it'll result in a token0 amount larger than the minimum. + let amount0_equivalent = get_equivalent_amount(amount1_max, balance1, balance0); + // This should never happen, as it'd imply that the maximum is lower than the minimum. + assert(amount0_equivalent <= amount0_max); + + assert(amount0_equivalent >= amount0_min, "AMOUNT_0_BELOW_MINIMUM"); + (amount0_equivalent, amount1_max) + } + } +} + +/// Returns the amount of tokens to return to a liquidity provider when they remove liquidity from the pool. +pub fn get_amounts_on_remove( + to_burn: u128, + total_supply: u128, + balance0: u128, + balance1: u128, +) -> (u128, u128) { + // Since the liquidity token tracks ownership of the pool, the liquidity provider gets a proportional share of each + // token. + (to_burn * balance0 / total_supply, to_burn * balance1 / total_supply) +} + +/// Given some amount of an asset and pair balances, returns an equivalent amount of the other asset. Tokens should be +/// added and removed from the Pool respecting this ratio. +fn get_equivalent_amount(amount0: u128, balance0: u128, balance1: u128) -> u128 { + assert((balance0 > 0 as u128) & (balance1 > 0 as u128), "INSUFFICIENT_LIQUIDITY"); + + // This is essentially the Rule of Three, since we're computing proportional ratios. Note we divide at the end to + // avoid introducing too much error due to truncation. + (amount0 * balance1) / balance0 +} diff --git a/contracts/amm/src/main.nr b/contracts/amm/src/main.nr new file mode 100644 index 0000000..3786a95 --- /dev/null +++ b/contracts/amm/src/main.nr @@ -0,0 +1,525 @@ +mod lib; +mod config; +mod test; + +use aztec::macros::aztec; + +/// ## Overview +/// This contract demonstrates how to implement an **Automated Market Maker (AMM)** that maintains **public state** +/// while still achieving **identity privacy**. However, it does **not provide function privacy**: +/// - Anyone can observe **what actions** were performed. +/// - All amounts involved are visible, but **who** performed the action remains private. +/// +/// Unlike most Ethereum AMMs, the AMM contract is not itself the token that tracks participation of liquidity +/// providers, mostly due to Noir lacking inheritance as a feature. Instead, the AMM is expected to have mint and burn +/// permission over an external token contract. +/// +/// **Note:** +/// This is purely a demonstration. The **Aztec team** does not consider this the optimal design for building a DEX. +/// +/// ## Reentrancy Guard Considerations +/// +/// ### 1. Private Functions: +/// Reentrancy protection is typically necessary if entering an intermediate state that is only valid when +/// the action completes uninterrupted. This follows the **Checks-Effects-Interactions** pattern. +/// +/// - In this contract, **private functions** do not introduce intermediate states. +/// - All operations will be fully executed in **public** without needing intermediate checks. +/// +/// ### 2. Public Functions: +/// No **reentrancy guard** is required for public functions because: +/// - All public functions are marked as **internal** with a **single callsite** - from a private function. +/// - Public functions **cannot call private functions**, eliminating the risk of reentering into them from private. +/// - Since public functions are internal-only, **external contracts cannot access them**, ensuring no external +/// contract can trigger a reentrant call. This eliminates the following attack vector: +/// `AMM.private_fn --> AMM.public_fn --> ExternalContract.fn --> AMM.public_fn`. +#[aztec] +pub contract AMM { + use crate::{ + config::Config, + lib::{get_amount_in, get_amount_out, get_amounts_on_remove, get_amounts_to_add}, + }; + use aztec::{ + macros::{functions::{authorize_once, external, initializer, only_self}, storage::storage}, + protocol::address::AztecAddress, + state_vars::PublicImmutable, + }; + + use token::Token; + use uint_note::PartialUintNote; + + #[storage] + struct Storage { + config: PublicImmutable, + } + + /// Amount of liquidity which gets locked when liquidity is provided for the first time. Its purpose is to prevent + /// the pool from ever emptying which could lead to undefined behavior. + pub global MINIMUM_LIQUIDITY: u128 = 1000; + /// We set it to 99 times the minimum liquidity. That way the first LP gets 99% of the value of their deposit. + pub global INITIAL_LIQUIDITY: u128 = 99000; + + // TODO(#9480): Either deploy the liquidity contract in the constructor or verify it that it corresponds to what + // this contract expects (i.e. that the AMM has permission to mint and burn). + #[external("public")] + #[initializer] + fn constructor(token0: AztecAddress, token1: AztecAddress, liquidity_token: AztecAddress) { + self.storage.config.initialize(Config { token0, token1, liquidity_token }); + } + + /// Privately adds liquidity to the pool. This function receives the minimum and maximum number of tokens the caller + /// is willing to add, in order to account for changing market conditions, and will try to add as many tokens as + /// possible. + /// + /// `authwit_nonce` can be any non-zero value, as it's only used to isolate token transfer authwits to this + /// specific call. + /// + /// The identity of the liquidity provider is not revealed, but the action and amounts are. + #[external("private")] + fn add_liquidity( + amount0_max: u128, + amount1_max: u128, + amount0_min: u128, + amount1_min: u128, + authwit_nonce: Field, + ) { + assert( + (amount0_min < amount0_max) | (amount0_min == amount0_max), + "INCORRECT_TOKEN0_LIMITS", + ); + assert( + (amount1_min < amount1_max) | (amount1_min == amount1_max), + "INCORRECT_TOKEN1_LIMITS", + ); + assert((0 as u128 < amount0_max) & (0 as u128 < amount1_max), "INSUFFICIENT_INPUT_AMOUNTS"); + + let config = self.storage.config.read(); + + let token0 = Token::at(config.token0); + let token1 = Token::at(config.token1); + let liquidity_token = Token::at(config.liquidity_token); + + let sender = self.msg_sender(); + + // We don't yet know how many tokens the sender will actually supply - that can only be computed during public + // execution since the amounts supplied must have the same ratio as the live balances. We therefore transfer the + // maximum amounts here, and prepare partial notes that return the change to the sender (if any). + let refund_token0_partial_note = self.call(token0 + .transfer_to_public_and_prepare_private_balance_increase( + sender, + self.address, + amount0_max, + authwit_nonce, + )); + + let refund_token1_partial_note = self.call(token1 + .transfer_to_public_and_prepare_private_balance_increase( + sender, + self.address, + amount1_max, + authwit_nonce, + )); + + // The number of liquidity tokens to mint for the caller depends on both the live balances and the amount + // supplied, both of which can only be known during public execution. We therefore prepare a partial note that + // will get completed via minting. + let liquidity_partial_note = + self.call(liquidity_token.prepare_private_balance_increase(sender)); + + // We then complete the flow in public. Note that the type of operation and amounts will all be publicly known, + // but the identity of the caller is not revealed despite us being able to send tokens to them by completing the + // partial notes. + self.enqueue_self._add_liquidity( + config, + refund_token0_partial_note, + refund_token1_partial_note, + liquidity_partial_note, + amount0_max, + amount1_max, + amount0_min, + amount1_min, + ); + } + + #[external("public")] + #[only_self] + fn _add_liquidity( + config: Config, // We could read this in public, but it's cheaper to receive from private + refund_token0_partial_note: PartialUintNote, + refund_token1_partial_note: PartialUintNote, + liquidity_partial_note: PartialUintNote, + amount0_max: u128, + amount1_max: u128, + amount0_min: u128, + amount1_min: u128, + ) { + let token0 = Token::at(config.token0); + let token1 = Token::at(config.token1); + let liquidity_token = Token::at(config.liquidity_token); + + // We read the current AMM balance of both tokens. Note that by the time this function is called the token + // transfers have already been completed (since those calls were enqueued before this call), and so we need to + // subtract the transfer amount to get the pre-deposit balance. + let balance0_plus_amount0_max = self.view(token0.balance_of_public(self.address)); + let balance0 = balance0_plus_amount0_max - amount0_max; + + let balance1_plus_amount1_max = self.view(token1.balance_of_public(self.address)); + let balance1 = balance1_plus_amount1_max - amount1_max; + + // With the current balances known, we can calculate the token amounts to the pool, respecting the user's + // minimum deposit preferences. + let (amount0, amount1) = get_amounts_to_add( + amount0_max, + amount1_max, + amount0_min, + amount1_min, + balance0, + balance1, + ); + + // Return any excess from the original token deposits. + let refund_amount_token0 = amount0_max - amount0; + let refund_amount_token1 = amount1_max - amount1; + + // We can simply skip the refund if the amount to return is 0 in order to save gas: the partial note will + // simply stay in public storage and not be completed, but this is not an issue. + if (refund_amount_token0 > 0 as u128) { + self.call(token0.finalize_transfer_to_private( + refund_amount_token0, + refund_token0_partial_note, + )); + } + if (refund_amount_token1 > 0 as u128) { + self.call(token1.finalize_transfer_to_private( + refund_amount_token1, + refund_token1_partial_note, + )); + } + + // With the deposit amounts known, we can compute the number of liquidity tokens to mint and finalize the + // depositor's partial note. + let total_supply = self.view(liquidity_token.total_supply()); + let liquidity_amount = if total_supply != 0 as u128 { + // The liquidity token supply increases by the same ratio as the balances. In case one of the token balances + // increased with a ratio different from the other one, we simply take the smallest value. + std::cmp::min( + (amount0 * total_supply) / balance0, + (amount1 * total_supply) / balance1, + ) + } else { + // The zero total supply case (i.e. pool initialization) is special as we can't increase the supply + // proportionally. We instead set the initial liquidity to an arbitrary amount. + // We could set the initial liquidity to be equal to the pool invariant (i.e. sqrt(amount0 * amount1)) if + // we wanted to collect protocol fees over swap fees (in the style of Uniswap v2), but we choose not to in + // order to keep things simple. + + // As part of initialization, we mint some tokens to the zero address to 'lock' them (i.e. make them + // impossible to redeem), guaranteeing total supply will never be zero again. + liquidity_token.mint_to_public(AztecAddress::zero(), MINIMUM_LIQUIDITY).call( + self.context, + ); + + INITIAL_LIQUIDITY + }; + + assert(liquidity_amount > 0 as u128, "INSUFFICIENT_LIQUIDITY_MINTED"); + liquidity_token.finalize_mint_to_private(liquidity_amount, liquidity_partial_note).call( + self.context, + ); + } + + /// Privately removes liquidity from the pool. This function receives how many liquidity tokens to burn, and the + /// minimum number of tokens the caller is willing to receive, in order to account for changing market conditions. + /// + /// `authwit_nonce` can be any non-zero value, as it's only used to isolate token transfer authwits to this + /// specific call. + /// + /// The identity of the liquidity provider is not revealed, but the action and amounts are. + #[external("private")] + fn remove_liquidity( + liquidity: u128, + amount0_min: u128, + amount1_min: u128, + authwit_nonce: Field, + ) { + let config = self.storage.config.read(); + + let liquidity_token = Token::at(config.liquidity_token); + let token0 = Token::at(config.token0); + let token1 = Token::at(config.token1); + + let sender = self.msg_sender(); + + // Liquidity tokens are burned when liquidity is removed in order to reduce the total supply. However, we lack + // a function to privately burn, so we instead transfer the tokens into the AMM's public balance, and then have + // the AMM publicly burn its own tokens. + // TODO(#10287): consider adding a private burn + self.call(liquidity_token.transfer_to_public(sender, self.address, liquidity, authwit_nonce)); + + // We don't yet know how many tokens the sender will get - that can only be computed during public execution + // since the it depends on the live balances. We therefore simply prepare partial notes to the sender. + let token0_partial_note = self.call(token0.prepare_private_balance_increase(sender)); + let token1_partial_note = self.call(token1.prepare_private_balance_increase(sender)); + + // We then complete the flow in public. Note that the type of operation and amounts will all be publicly known, + // but the identity of the caller is not revealed despite us being able to send tokens to them by completing the + // partial notes. + self.enqueue_self._remove_liquidity( + config, + liquidity, + token0_partial_note, + token1_partial_note, + amount0_min, + amount1_min, + ); + } + + #[external("public")] + #[only_self] + fn _remove_liquidity( + config: Config, // We could read this in public, but it's cheaper to receive from private + liquidity: u128, + token0_partial_note: PartialUintNote, + token1_partial_note: PartialUintNote, + amount0_min: u128, + amount1_min: u128, + ) { + let token0 = Token::at(config.token0); + let token1 = Token::at(config.token1); + let liquidity_token = Token::at(config.liquidity_token); + + // We need the current balance of both tokens as well as the liquidity token total supply in order to compute + // the amounts to send the user. + let balance0 = self.view(token0.balance_of_public(self.address)); + let balance1 = self.view(token1.balance_of_public(self.address)); + let total_supply = self.view(liquidity_token.total_supply()); + + // We calculate the amounts of token0 and token1 the user is entitled to based on the amount of liquidity they + // are removing, and check that they are above the minimum amounts they requested. + let (amount0, amount1) = get_amounts_on_remove(liquidity, total_supply, balance0, balance1); + assert(amount0 >= amount0_min, "INSUFFICIENT_0_AMOUNT"); + assert(amount1 >= amount1_min, "INSUFFICIENT_1_AMOUNT"); + + // We can now burn the liquidity tokens that had been privately transferred into the AMM, as well as complete + // both partial notes. + self.call(liquidity_token.burn_public(self.address, liquidity, 0)); + self.call(token0.finalize_transfer_to_private(amount0, token0_partial_note)); + self.call(token1.finalize_transfer_to_private(amount1, token1_partial_note)); + } + + /// Privately swaps `amount_in` `token_in` tokens for at least `amount_out_mint` `token_out` tokens with the pool. + /// + /// `authwit_nonce` can be any non-zero value, as it's only used to isolate token transfer authwits to this + /// specific call. + /// + /// The identity of the swapper is not revealed, but the action and amounts are. + #[external("private")] + fn swap_exact_tokens_for_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_in: u128, + amount_out_min: u128, + authwit_nonce: Field, + ) { + let config = self.storage.config.read(); + + assert((token_in == config.token0) | (token_in == config.token1), "TOKEN_IN_IS_INVALID"); + assert((token_out == config.token0) | (token_out == config.token1), "TOKEN_OUT_IS_INVALID"); + assert(token_in != token_out, "SAME_TOKEN_SWAP"); + + let sender = self.msg_sender(); + + // We transfer the full amount in, since it is an exact amount, and prepare a partial note for the amount out, + // which will only be known during public execution as it depends on the live balances. + self.call(Token::at(token_in).transfer_to_public( + sender, + self.address, + amount_in, + authwit_nonce, + )); + let token_out_partial_note = + self.call(Token::at(token_out).prepare_private_balance_increase(sender)); + + self.enqueue_self._swap_exact_tokens_for_tokens( + token_in, + token_out, + amount_in, + amount_out_min, + token_out_partial_note, + ); + } + + #[external("public")] + #[only_self] + fn _swap_exact_tokens_for_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_in: u128, + amount_out_min: u128, + token_out_partial_note: PartialUintNote, + ) { + // In order to compute the amount to swap we need the live token balances. Note that at this state the token in + // transfer has already been completed as that function call was enqueued before this one. We therefore need to + // subtract the amount in to get the pre-swap balances. + let balance_in_plus_amount_in = + self.view(Token::at(token_in).balance_of_public(self.address)); + let balance_in = balance_in_plus_amount_in - amount_in; + + let balance_out = self.view(Token::at(token_out).balance_of_public(self.address)); + + // We can now compute the number of tokens to transfer and complete the partial note. + let amount_out = get_amount_out(amount_in, balance_in, balance_out); + assert(amount_out >= amount_out_min, "INSUFFICIENT_OUTPUT_AMOUNT"); + + Token::at(token_out).finalize_transfer_to_private(amount_out, token_out_partial_note).call( + self.context, + ); + } + + /// Privately swaps at most `amount_in_max` `token_in` tokens for `amount_out` `token_out` tokens with the pool. + /// + /// `authwit_nonce` can be any non-zero value, as it's only used to isolate token transfer authwits to this + /// specific call. + /// + /// The identity of the swapper is not revealed, but the action and amounts are. + #[external("private")] + fn swap_tokens_for_exact_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_out: u128, + amount_in_max: u128, + authwit_nonce: Field, + ) { + let config = self.storage.config.read(); + + assert((token_in == config.token0) | (token_in == config.token1), "TOKEN_IN_IS_INVALID"); + assert((token_out == config.token0) | (token_out == config.token1), "TOKEN_OUT_IS_INVALID"); + assert(token_in != token_out, "SAME_TOKEN_SWAP"); + + let sender = self.msg_sender(); + + // We don't know how many tokens we'll receive from the user, since the swap amount will only be known during + // public execution as it depends on the live balances. We therefore transfer the full maximum amount and + // prepare partial notes both for the token out and the refund. + // Technically the token out note does not need to be partial, since we do know the amount out, but we do want + // to wait until the swap has been completed before committing the note to the tree to avoid it being spent too + // early. + let change_token_in_partial_note = self.call(Token::at(token_in) + .transfer_to_public_and_prepare_private_balance_increase( + sender, + self.address, + amount_in_max, + authwit_nonce, + )); + + let token_out_partial_note = + self.call(Token::at(token_out).prepare_private_balance_increase(sender)); + + self.enqueue_self._swap_tokens_for_exact_tokens( + token_in, + token_out, + amount_in_max, + amount_out, + change_token_in_partial_note, + token_out_partial_note, + ); + } + + /// Same as `swap_tokens_for_exact_tokens` but accepts an explicit `from` address. + /// This allows a third party (e.g. a Fee Payment Contract) to call this function on + /// behalf of the user. The caller must be authorized via an authwit from `from`. + #[authorize_once("from", "authwit_nonce")] + #[external("private")] + fn swap_tokens_for_exact_tokens_from( + from: AztecAddress, + token_in: AztecAddress, + token_out: AztecAddress, + amount_out: u128, + amount_in_max: u128, + authwit_nonce: Field, + ) { + let config = self.storage.config.read(); + + assert((token_in == config.token0) | (token_in == config.token1), "TOKEN_IN_IS_INVALID"); + assert((token_out == config.token0) | (token_out == config.token1), "TOKEN_OUT_IS_INVALID"); + assert(token_in != token_out, "SAME_TOKEN_SWAP"); + + let change_token_in_partial_note = self.call(Token::at(token_in) + .transfer_to_public_and_prepare_private_balance_increase( + from, + self.address, + amount_in_max, + authwit_nonce, + )); + + let token_out_partial_note = + self.call(Token::at(token_out).prepare_private_balance_increase(from)); + + self.enqueue_self._swap_tokens_for_exact_tokens( + token_in, + token_out, + amount_in_max, + amount_out, + change_token_in_partial_note, + token_out_partial_note, + ); + } + + #[external("public")] + #[only_self] + fn _swap_tokens_for_exact_tokens( + token_in: AztecAddress, + token_out: AztecAddress, + amount_in_max: u128, + amount_out: u128, + change_token_in_partial_note: PartialUintNote, + token_out_partial_note: PartialUintNote, + ) { + // In order to compute the amount to swap we need the live token balances. Note that at this state the token in + // transfer has already been completed as that function call was enqueued before this one. We therefore need to + // subtract the amount in to get the pre-swap balances. + let balance_in_plus_amount_in_max = + self.view(Token::at(token_in).balance_of_public(self.address)); + let balance_in = balance_in_plus_amount_in_max - amount_in_max; + + let balance_out = self.view(Token::at(token_out).balance_of_public(self.address)); + + // We can now compute the number of tokens we need to receive and complete the partial note with the change. + let amount_in = get_amount_in(amount_out, balance_in, balance_out); + assert(amount_in <= amount_in_max, "INSUFFICIENT_OUTPUT_AMOUNT"); + + let change = amount_in_max - amount_in; + if (change > 0 as u128) { + self.call(Token::at(token_in).finalize_transfer_to_private( + change, + change_token_in_partial_note, + )); + } + + // Note again that we already knew the amount out, but for consistency we want to only commit this note once + // all other steps have been performed. + Token::at(token_out).finalize_transfer_to_private(amount_out, token_out_partial_note).call( + self.context, + ); + } + + #[external("utility")] + unconstrained fn get_amount_out_for_exact_in( + balance_in: u128, + balance_out: u128, + amount_in: u128, + ) -> u128 { + // Ideally we'd call the token contract in order to read the current balance, but we can't due to #7524. + get_amount_out(amount_in, balance_in, balance_out) + } + + #[external("utility")] + unconstrained fn get_amount_in_for_exact_out( + balance_in: u128, + balance_out: u128, + amount_out: u128, + ) -> u128 { + // Ideally we'd call the token contract in order to read the current balance, but we can't due to #7524. + get_amount_in(amount_out, balance_in, balance_out) + } +} diff --git a/contracts/amm/src/test/mod.nr b/contracts/amm/src/test/mod.nr new file mode 100644 index 0000000..d091e92 --- /dev/null +++ b/contracts/amm/src/test/mod.nr @@ -0,0 +1,2 @@ +mod test; +pub(crate) mod utils; diff --git a/contracts/amm/src/test/test.nr b/contracts/amm/src/test/test.nr new file mode 100644 index 0000000..e97677d --- /dev/null +++ b/contracts/amm/src/test/test.nr @@ -0,0 +1,594 @@ +use crate::{AMM, test::utils::{add_liquidity, remove_liquidity, setup}}; +use aztec::{ + protocol::{address::AztecAddress, traits::FromField}, + test::helpers::authwit::add_private_authwit_from_call, +}; +use token::Token; + +global AUTHWIT_NONCE: Field = 1; +global DEFAULT_AMOUNT_0_MIN: u128 = 0; +global DEFAULT_AMOUNT_1_MIN: u128 = 0; + +#[test] +unconstrained fn add_liquidity_twice_and_remove_liquidity() { + let (mut env, amm_address, token0_address, token1_address, liquidity_token_address, minter) = + setup(); + + let liquidity_provider_1 = env.create_contract_account(); + let liquidity_provider_2 = env.create_contract_account(); + + let token0 = Token::at(token0_address); + let token1 = Token::at(token1_address); + let liquidity_token = Token::at(liquidity_token_address); + + // ADDING INITIAL LIQUIDITY + let initial_amount0 = 1000 as u128; + let initial_amount1 = 2000 as u128; + + add_liquidity( + env, + amm_address, + token0_address, + token1_address, + minter, + liquidity_provider_1, + initial_amount0, + initial_amount1, + DEFAULT_AMOUNT_0_MIN, + DEFAULT_AMOUNT_1_MIN, + ); + + // We verify the initial liquidity token supply is as expected as we will need the value later on to calculate + // the expected tokens received. + let initial_liquidity_token_supply = env.view_public(liquidity_token.total_supply()); + assert_eq(initial_liquidity_token_supply, AMM::MINIMUM_LIQUIDITY + AMM::INITIAL_LIQUIDITY); + + // ADDING LIQUIDITY AGAIN + let expected_amount_0_in = initial_amount0 / 2; + // The amount of token1 that is expected to be deposited. + let expected_amount_1_in = initial_amount1 / 2; + // We intentionally set the amount1_max to be higher than the liquidity ration to check that we get a refund. + let expected_refund_amount1 = 200 as u128; + let amount1_max = expected_amount_1_in + expected_refund_amount1; + + add_liquidity( + env, + amm_address, + token0_address, + token1_address, + minter, + liquidity_provider_2, + expected_amount_0_in, + amount1_max, + DEFAULT_AMOUNT_0_MIN, + DEFAULT_AMOUNT_1_MIN, + ); + + // Verify balances after adding liquidity + // AMM should have received 500 token0 and 1000 token1 (not 1200, as excess is refunded) + assert_eq( + env.view_public(token0.balance_of_public(amm_address)), + initial_amount0 + expected_amount_0_in, + ); + assert_eq( + env.view_public(token1.balance_of_public(amm_address)), + initial_amount1 + expected_amount_1_in, + ); + + // Liquidity provider 2 should have 0 token0 and the refund amount of token1 + assert_eq(env.execute_utility(token0.balance_of_private(liquidity_provider_2)), 0); + assert_eq( + env.execute_utility(token1.balance_of_private(liquidity_provider_2)), + expected_refund_amount1, + ); + + // Check liquidity provider 2 received liquidity tokens proportional to their contribution + let expected_liquidity_tokens = + (expected_amount_0_in * initial_liquidity_token_supply) / initial_amount0; + assert_eq( + env.execute_utility(liquidity_token.balance_of_private(liquidity_provider_2)), + expected_liquidity_tokens, + ); + + // REMOVING LIQUIDITY + let liquidity_to_remove = AMM::INITIAL_LIQUIDITY / 2; + let amount0_min = 400 as u128; + let amount1_min = 800 as u128; + + remove_liquidity( + env, + amm_address, + liquidity_token_address, + liquidity_provider_1, + liquidity_to_remove, + amount0_min, + amount1_min, + ); + + // Verify liquidity provider 1 got tokens back (proportional to liquidity burned) + let expected_token0_back = + (liquidity_to_remove * initial_amount0) / initial_liquidity_token_supply; + let expected_token1_back = + (liquidity_to_remove * initial_amount1) / initial_liquidity_token_supply; + assert_eq( + env.execute_utility(token0.balance_of_private(liquidity_provider_1)), + expected_token0_back, + ); + assert_eq( + env.execute_utility(token1.balance_of_private(liquidity_provider_1)), + expected_token1_back, + ); + + // Check remaining liquidity tokens + assert_eq( + env.execute_utility(liquidity_token.balance_of_private(liquidity_provider_1)), + // The expected remaining liquidity is the other half of the initial liquidity. + AMM::INITIAL_LIQUIDITY / 2, + ); +} + +#[test] +unconstrained fn swap_exact_tokens_for_tokens() { + let (mut env, amm_address, token0_address, token1_address, _liquidity_token_address, minter) = + setup(); + + let liquidity_provider = env.create_contract_account(); + let swapper = env.create_contract_account(); + + let token0 = Token::at(token0_address); + let token1 = Token::at(token1_address); + let amm = AMM::at(amm_address); + + // First add liquidity to the pool + let liquidity_amount0 = 10000 as u128; + let liquidity_amount1 = 20000 as u128; + + add_liquidity( + env, + amm_address, + token0_address, + token1_address, + minter, + liquidity_provider, + liquidity_amount0, + liquidity_amount1, + DEFAULT_AMOUNT_0_MIN, + DEFAULT_AMOUNT_1_MIN, + ); + + // Now perform a swap + let amount_in = 1000 as u128; + let amount_out_min = 1800 as u128; // Expect ~1814 with 0.3% fee + + env.call_private(minter, token0.mint_to_private(swapper, amount_in)); + + // Create authwit for transferring tokens to AMM + add_private_authwit_from_call( + env, + swapper, + amm_address, + token0.transfer_to_public(swapper, amm_address, amount_in, AUTHWIT_NONCE), + ); + + env.call_private( + swapper, + amm.swap_exact_tokens_for_tokens( + token0_address, + token1_address, + amount_in, + amount_out_min, + AUTHWIT_NONCE, + ), + ); + + // Verify swap occurred - all of input tokens should be spent and hence the swapper should have 0 token0 balance. + assert_eq(env.execute_utility(token0.balance_of_private(swapper)), 0); + // The exact amount out depends on the AMM formula, but should be > amount_out_min + assert(env.execute_utility(token1.balance_of_private(swapper)) >= amount_out_min); +} + +#[test] +unconstrained fn swap_tokens_for_exact_tokens() { + let (mut env, amm_address, token0_address, token1_address, _liquidity_token_address, minter) = + setup(); + + let liquidity_provider = env.create_contract_account(); + let swapper = env.create_contract_account(); + + let token0 = Token::at(token0_address); + let token1 = Token::at(token1_address); + let amm = AMM::at(amm_address); + + // First add liquidity to the pool + let liquidity_amount0 = 10000 as u128; + let liquidity_amount1 = 20000 as u128; + + add_liquidity( + env, + amm_address, + token0_address, + token1_address, + minter, + liquidity_provider, + liquidity_amount0, + liquidity_amount1, + DEFAULT_AMOUNT_0_MIN, + DEFAULT_AMOUNT_1_MIN, + ); + + // Now perform a swap for exact tokens out + let amount_out = 1000 as u128; + let amount_in_max = 600 as u128; // Should need ~503 tokens in with 0.3% fee + + env.call_private(minter, token0.mint_to_private(swapper, amount_in_max)); + + // Create authwit for transferring tokens to AMM + let transfer_call = token0.transfer_to_public_and_prepare_private_balance_increase( + swapper, + amm_address, + amount_in_max, + AUTHWIT_NONCE, + ); + add_private_authwit_from_call(env, swapper, amm_address, transfer_call); + + env.call_private( + swapper, + amm.swap_tokens_for_exact_tokens( + token0_address, + token1_address, + amount_out, + amount_in_max, + AUTHWIT_NONCE, + ), + ); + + // Verify swap occurred - should get exact amount out + assert_eq(env.execute_utility(token1.balance_of_private(swapper)), amount_out); + // Should have some token0 change returned + let swapper_token0_balance = env.execute_utility(token0.balance_of_private(swapper)); + assert(swapper_token0_balance > 0); + assert(swapper_token0_balance < amount_in_max); +} + +#[test(should_fail_with = "INCORRECT_TOKEN0_LIMITS")] +unconstrained fn add_liquidity_incorrect_token0_limits() { + let (mut env, amm_address, _token0_address, _token1_address, _liquidity_token_address, _minter) + = setup(); + + let liquidity_provider = env.create_contract_account(); + let amm = AMM::at(amm_address); + + // amount0_min > amount0_max should fail + let amount0_max = 500 as u128; + let amount1_max = 1000 as u128; + let amount0_min = 600 as u128; // Invalid: min > max + let amount1_min = 500 as u128; + + env.call_private( + liquidity_provider, + amm.add_liquidity(amount0_max, amount1_max, amount0_min, amount1_min, AUTHWIT_NONCE), + ); +} + +#[test(should_fail_with = "INCORRECT_TOKEN1_LIMITS")] +unconstrained fn add_liquidity_incorrect_token1_limits() { + let (mut env, amm_address, _token0_address, _token1_address, _liquidity_token_address, _minter) + = setup(); + + let liquidity_provider = env.create_contract_account(); + let amm = AMM::at(amm_address); + + // amount1_min > amount1_max should fail + let amount0_max = 500 as u128; + let amount1_max = 1000 as u128; + let amount0_min = 400 as u128; + let amount1_min = 1100 as u128; // Invalid: min > max + + env.call_private( + liquidity_provider, + amm.add_liquidity(amount0_max, amount1_max, amount0_min, amount1_min, AUTHWIT_NONCE), + ); +} + +#[test(should_fail_with = "INSUFFICIENT_INPUT_AMOUNTS")] +unconstrained fn add_liquidity_zero_amount0_max() { + let (mut env, amm_address, _token0_address, _token1_address, _liquidity_token_address, _minter) + = setup(); + + let liquidity_provider = env.create_contract_account(); + let amm = AMM::at(amm_address); + + let amount0_max = 0 as u128; // Invalid: zero amount + let amount1_max = 1000 as u128; + let amount0_min = 0 as u128; + let amount1_min = 500 as u128; + + env.call_private( + liquidity_provider, + amm.add_liquidity(amount0_max, amount1_max, amount0_min, amount1_min, AUTHWIT_NONCE), + ); +} + +#[test(should_fail_with = "INSUFFICIENT_INPUT_AMOUNTS")] +unconstrained fn add_liquidity_zero_amount1_max() { + let (mut env, amm_address, _token0_address, _token1_address, _liquidity_token_address, _minter) + = setup(); + + let liquidity_provider = env.create_contract_account(); + let amm = AMM::at(amm_address); + + let amount0_max = 1000 as u128; + let amount1_max = 0 as u128; // Invalid: zero amount + let amount0_min = 500 as u128; + let amount1_min = 0 as u128; + + env.call_private( + liquidity_provider, + amm.add_liquidity(amount0_max, amount1_max, amount0_min, amount1_min, AUTHWIT_NONCE), + ); +} + +#[test(should_fail_with = "TOKEN_IN_IS_INVALID")] +unconstrained fn swap_exact_tokens_invalid_token_in() { + let (mut env, amm_address, _token0_address, token1_address, _liquidity_token_address, _minter) + = setup(); + + let swapper = env.create_contract_account(); + let amm = AMM::at(amm_address); + + let invalid_token = AztecAddress::from_field(999); + let amount_in = 1000 as u128; + let amount_out_min = 900 as u128; + + env.call_private( + swapper, + amm.swap_exact_tokens_for_tokens( + invalid_token, + token1_address, + amount_in, + amount_out_min, + AUTHWIT_NONCE, + ), + ); +} + +#[test(should_fail_with = "TOKEN_OUT_IS_INVALID")] +unconstrained fn swap_exact_tokens_invalid_token_out() { + let (mut env, amm_address, token0_address, _token1_address, _liquidity_token_address, _minter) + = setup(); + + let swapper = env.create_contract_account(); + let amm = AMM::at(amm_address); + + let invalid_token = AztecAddress::from_field(999); + let amount_in = 1000 as u128; + let amount_out_min = 900 as u128; + + env.call_private( + swapper, + amm.swap_exact_tokens_for_tokens( + token0_address, + invalid_token, + amount_in, + amount_out_min, + AUTHWIT_NONCE, + ), + ); +} + +#[test(should_fail_with = "SAME_TOKEN_SWAP")] +unconstrained fn swap_exact_tokens_same_tokens() { + let (mut env, amm_address, token0_address, _token1_address, _liquidity_token_address, _minter) + = setup(); + + let swapper = env.create_contract_account(); + let amm = AMM::at(amm_address); + + let amount_in = 1000 as u128; + let amount_out_min = 900 as u128; + + env.call_private( + swapper, + amm.swap_exact_tokens_for_tokens( + token0_address, + token0_address, // Same token for in and out + amount_in, + amount_out_min, + AUTHWIT_NONCE, + ), + ); +} + +#[test(should_fail_with = "TOKEN_IN_IS_INVALID")] +unconstrained fn swap_tokens_for_exact_invalid_token_in() { + let (mut env, amm_address, _token0_address, token1_address, _liquidity_token_address, _minter) + = setup(); + + let swapper = env.create_contract_account(); + let amm = AMM::at(amm_address); + + let invalid_token = AztecAddress::from_field(999); + let amount_out = 1000 as u128; + let amount_in_max = 1200 as u128; + + env.call_private( + swapper, + amm.swap_tokens_for_exact_tokens( + invalid_token, + token1_address, + amount_out, + amount_in_max, + AUTHWIT_NONCE, + ), + ); +} + +#[test(should_fail_with = "TOKEN_OUT_IS_INVALID")] +unconstrained fn swap_tokens_for_exact_invalid_token_out() { + let (mut env, amm_address, token0_address, _token1_address, _liquidity_token_address, _minter) + = setup(); + + let swapper = env.create_contract_account(); + let amm = AMM::at(amm_address); + + let invalid_token = AztecAddress::from_field(999); + let amount_out = 1000 as u128; + let amount_in_max = 1200 as u128; + + env.call_private( + swapper, + amm.swap_tokens_for_exact_tokens( + token0_address, + invalid_token, + amount_out, + amount_in_max, + AUTHWIT_NONCE, + ), + ); +} + +#[test(should_fail_with = "SAME_TOKEN_SWAP")] +unconstrained fn swap_tokens_for_exact_same_tokens() { + let (mut env, amm_address, token0_address, _token1_address, _liquidity_token_address, _minter) + = setup(); + + let swapper = env.create_contract_account(); + let amm = AMM::at(amm_address); + + let amount_out = 1000 as u128; + let amount_in_max = 1200 as u128; + + env.call_private( + swapper, + amm.swap_tokens_for_exact_tokens( + token0_address, + token0_address, // Same token for in and out + amount_out, + amount_in_max, + AUTHWIT_NONCE, + ), + ); +} + +#[test(should_fail_with = "INSUFFICIENT_LIQUIDITY_MINTED")] +unconstrained fn add_liquidity_insufficient_liquidity_minted() { + let (mut env, amm_address, token0_address, token1_address, _liquidity_token_address, minter) = + setup(); + + let liquidity_provider_1 = env.create_contract_account(); + let liquidity_provider_2 = env.create_contract_account(); + + // Add initial liquidity first + let initial_amount0 = 100000000 as u128; // Very large amount + let initial_amount1 = 200000000 as u128; + + add_liquidity( + env, + amm_address, + token0_address, + token1_address, + minter, + liquidity_provider_1, + initial_amount0, + initial_amount1, + DEFAULT_AMOUNT_0_MIN, + DEFAULT_AMOUNT_1_MIN, + ); + + // Now try to add very small liquidity that would result in 0 liquidity tokens + let tiny_amount0 = 1 as u128; // Very small amount + let tiny_amount1 = 2 as u128; + + add_liquidity( + env, + amm_address, + token0_address, + token1_address, + minter, + liquidity_provider_2, + tiny_amount0, + tiny_amount1, + DEFAULT_AMOUNT_0_MIN, + DEFAULT_AMOUNT_1_MIN, + ); +} + +#[test(should_fail_with = "INSUFFICIENT_0_AMOUNT")] +unconstrained fn remove_liquidity_insufficient_amount0() { + let (mut env, amm_address, token0_address, token1_address, liquidity_token_address, minter) = + setup(); + + let liquidity_provider = env.create_contract_account(); + + // Add liquidity first + let amount0 = 1000 as u128; + let amount1 = 2000 as u128; + + add_liquidity( + env, + amm_address, + token0_address, + token1_address, + minter, + liquidity_provider, + amount0, + amount1, + DEFAULT_AMOUNT_0_MIN, + DEFAULT_AMOUNT_1_MIN, + ); + + // Try to remove liquidity with unrealistic minimum expectations + let liquidity_to_remove = AMM::INITIAL_LIQUIDITY / 2; + let amount0_min = 10000 as u128; // Unrealistically high minimum + let amount1_min = 800 as u128; + + remove_liquidity( + env, + amm_address, + liquidity_token_address, + liquidity_provider, + liquidity_to_remove, + amount0_min, + amount1_min, + ); +} + +#[test(should_fail_with = "INSUFFICIENT_1_AMOUNT")] +unconstrained fn remove_liquidity_insufficient_amount1() { + let (mut env, amm_address, token0_address, token1_address, liquidity_token_address, minter) = + setup(); + + let liquidity_provider = env.create_contract_account(); + + // Add liquidity first + let amount0 = 1000 as u128; + let amount1 = 2000 as u128; + + add_liquidity( + env, + amm_address, + token0_address, + token1_address, + minter, + liquidity_provider, + amount0, + amount1, + DEFAULT_AMOUNT_0_MIN, + DEFAULT_AMOUNT_1_MIN, + ); + + // Try to remove liquidity with unrealistic minimum expectations + let liquidity_to_remove = AMM::INITIAL_LIQUIDITY / 2; + let amount0_min = 400 as u128; + let amount1_min = 20000 as u128; // Unrealistically high minimum + + remove_liquidity( + env, + amm_address, + liquidity_token_address, + liquidity_provider, + liquidity_to_remove, + amount0_min, + amount1_min, + ); +} diff --git a/contracts/amm/src/test/utils.nr b/contracts/amm/src/test/utils.nr new file mode 100644 index 0000000..a962c2e --- /dev/null +++ b/contracts/amm/src/test/utils.nr @@ -0,0 +1,134 @@ +use crate::AMM; +use aztec::{ + protocol::address::AztecAddress, + test::helpers::{authwit::add_private_authwit_from_call, test_environment::TestEnvironment}, +}; +use token::Token; + +global AUTHWIT_NONCE: Field = 1; + +// Sets up test env with AMM with three tokens and an admin account. +// TODO(#16560): Make it possible to return a contract instance directly from setup func +pub(crate) unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress, AztecAddress, AztecAddress) { + let mut env = TestEnvironment::new(); + + // Setup admin account + let admin = env.create_contract_account(); + + // Deploy tokens + let token0_initializer = Token::interface().constructor( + admin, + "Token00000000000000000000000000", + "TK00000000000000000000000000000", + 18, + ); + let token0_address = + env.deploy("@token_contract/Token").with_public_initializer(admin, token0_initializer); + + let token1_initializer = Token::interface().constructor( + admin, + "Token11111111111111111111111111", + "TK11111111111111111111111111111", + 18, + ); + let token1_address = + env.deploy("@token_contract/Token").with_public_initializer(admin, token1_initializer); + + let liquidity_token_initializer = Token::interface().constructor( + admin, + "LiquidityToken00000000000000000", + "LT00000000000000000000000000000", + 18, + ); + let liquidity_token_address = env.deploy("@token_contract/Token").with_public_initializer( + admin, + liquidity_token_initializer, + ); + + // Deploy AMM contract + let amm_initializer = + AMM::interface().constructor(token0_address, token1_address, liquidity_token_address); + let amm_address = env.deploy("AMM").with_public_initializer(admin, amm_initializer); + + // Set AMM as minter for liquidity token + env.call_public(admin, Token::at(liquidity_token_address).set_minter(amm_address, true)); + + // The admin has the minter role. Since we only care about the minting capability in the tests, we return the admin + // address as 'minter' rather than 'admin'. + let minter = admin; + + (env, amm_address, token0_address, token1_address, liquidity_token_address, minter) +} + +pub(crate) unconstrained fn add_liquidity( + env: TestEnvironment, + amm_address: AztecAddress, + token0_address: AztecAddress, + token1_address: AztecAddress, + minter: AztecAddress, + liquidity_provider: AztecAddress, + amount0_max: u128, + amount1_max: u128, + amount0_min: u128, + amount1_min: u128, +) { + let token0 = Token::at(token0_address); + let token1 = Token::at(token1_address); + let amm = AMM::at(amm_address); + + // Mint tokens to liquidity provider + env.call_private(minter, token0.mint_to_private(liquidity_provider, amount0_max)); + env.call_private(minter, token1.mint_to_private(liquidity_provider, amount1_max)); + + let transfer0_call = token0.transfer_to_public_and_prepare_private_balance_increase( + liquidity_provider, + amm_address, + amount0_max, + AUTHWIT_NONCE, + ); + add_private_authwit_from_call(env, liquidity_provider, amm_address, transfer0_call); + + let transfer1_call = token1.transfer_to_public_and_prepare_private_balance_increase( + liquidity_provider, + amm_address, + amount1_max, + AUTHWIT_NONCE, + ); + add_private_authwit_from_call(env, liquidity_provider, amm_address, transfer1_call); + + env.call_private( + liquidity_provider, + amm.add_liquidity(amount0_max, amount1_max, amount0_min, amount1_min, AUTHWIT_NONCE), + ); +} + +pub(crate) unconstrained fn remove_liquidity( + env: TestEnvironment, + amm_address: AztecAddress, + liquidity_token_address: AztecAddress, + liquidity_provider: AztecAddress, + liquidity_amount: u128, + amount0_min: u128, + amount1_min: u128, +) { + let liquidity_token = Token::at(liquidity_token_address); + let amm = AMM::at(amm_address); + + let transfer_liquidity_call = liquidity_token.transfer_to_public( + liquidity_provider, + amm_address, + liquidity_amount, + AUTHWIT_NONCE, + ); + add_private_authwit_from_call( + env, + liquidity_provider, + amm_address, + transfer_liquidity_call, + ); + + env.call_private( + liquidity_provider, + amm.remove_liquidity(liquidity_amount, amount0_min, amount1_min, AUTHWIT_NONCE), + ); +} diff --git a/contracts/proof_of_password/Nargo.toml b/contracts/proof_of_password/Nargo.toml index 67d2f55..37d6ff2 100644 --- a/contracts/proof_of_password/Nargo.toml +++ b/contracts/proof_of_password/Nargo.toml @@ -4,7 +4,7 @@ type = "contract" authors = [""] [dependencies] -aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.3", directory = "noir-projects/aztec-nr/aztec" } -token = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.3", directory = "noir-projects/noir-contracts/contracts/app/token_contract" } +aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "noir-projects/aztec-nr/aztec" } +token = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "noir-projects/noir-contracts/contracts/app/token_contract" } poseidon = { tag = "v0.1.1", git = "https://github.com/noir-lang/poseidon" } -compressed_string = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.3", directory = "noir-projects/aztec-nr/compressed-string" } \ No newline at end of file +compressed_string = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "noir-projects/aztec-nr/compressed-string" } \ No newline at end of file diff --git a/contracts/proof_of_password/src/test/mod.nr b/contracts/proof_of_password/src/test/mod.nr index 82c6ae6..ae75aff 100644 --- a/contracts/proof_of_password/src/test/mod.nr +++ b/contracts/proof_of_password/src/test/mod.nr @@ -47,7 +47,7 @@ unconstrained fn mints_on_correct_password() { let token = Token::at(token_contract_address); - let balance = env.simulate_utility(token.balance_of_private(recipient)); + let balance = env.execute_utility(token.balance_of_private(recipient)); assert(balance == 1000, "Token was not minted") } diff --git a/package.json b/package.json index b712ac2..8988b20 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,20 @@ "type": "module", "scripts": { "clean": "rm -rf ./dist .tsbuildinfo", - "serve": "vite", + "dev": "NODE_OPTIONS=--max_http_header_size=128000 vite", "build": "tsc -b && vite build", "lint": "eslint .", - "copy:dependencies": "cd contracts && nargo check && WORKDIR=$(pwd) && cd $HOME/nargo/github.com/AztecProtocol/aztec-packages/v4.0.0-devnet.2-patch.3/noir-projects/noir-contracts && aztec compile --package token_contract && mkdir -p $WORKDIR/target && cp $HOME/nargo/github.com/AztecProtocol/aztec-packages/v4.0.0-devnet.2-patch.3/noir-projects/noir-contracts/target/token_contract-Token.json $WORKDIR/target/token_contract-Token.json", - "compile:contracts": "cd contracts && aztec compile --package proof_of_password && aztec codegen ./target/proof_of_password-ProofOfPassword.json", + "copy:dependencies": "WORKDIR=$(pwd) && mkdir -p $WORKDIR/contracts/target && cp $WORKDIR/node_modules/@aztec/noir-contracts.js/artifacts/token_contract-Token.json $WORKDIR/contracts/target/token_contract-Token.json", + "compile:contracts": "cd contracts && aztec compile && aztec codegen ./target -o ./target", "test": "cd contracts && aztec test", - "preview": "vite preview", + "add-fpc": "node --experimental-transform-types scripts/add-subscription-fpc.ts", + "preview": "NODE_OPTIONS=--max_http_header_size=128000 vite preview", "deploy:local": "node --experimental-transform-types scripts/deploy.ts --network local", "deploy:devnet": "node --experimental-transform-types scripts/deploy.ts --network devnet", "deploy:nextnet": "node --experimental-transform-types scripts/deploy.ts --network nextnet", + "deploy:testnet": "node --experimental-transform-types scripts/deploy.ts --network testnet", + "mint:local": "node --experimental-transform-types scripts/mint.ts --network local", + "mint:testnet": "node --experimental-transform-types scripts/mint.ts --network testnet", "formatting": "run -T prettier --check ./src && run -T eslint ./src", "formatting:fix": "run -T eslint --fix ./src && run -T prettier -w ./src", "local-aztec:enable": "node scripts/toggle-local-aztec.js enable && corepack yarn install", @@ -23,18 +27,20 @@ "local-aztec:status": "node scripts/toggle-local-aztec.js status" }, "dependencies": { - "@aztec/accounts": "v4.0.0-devnet.2-patch.3", - "@aztec/aztec.js": "v4.0.0-devnet.2-patch.3", - "@aztec/constants": "v4.0.0-devnet.2-patch.3", - "@aztec/entrypoints": "v4.0.0-devnet.2-patch.3", - "@aztec/foundation": "v4.0.0-devnet.2-patch.3", - "@aztec/noir-contracts.js": "v4.0.0-devnet.2-patch.3", - "@aztec/protocol-contracts": "v4.0.0-devnet.2-patch.3", - "@aztec/pxe": "v4.0.0-devnet.2-patch.3", - "@aztec/stdlib": "v4.0.0-devnet.2-patch.3", - "@aztec/wallet-sdk": "v4.0.0-devnet.2-patch.3", + "@aztec/accounts": "v4.2.0-aztecnr-rc.2", + "@aztec/aztec.js": "v4.2.0-aztecnr-rc.2", + "@aztec/constants": "v4.2.0-aztecnr-rc.2", + "@aztec/entrypoints": "v4.2.0-aztecnr-rc.2", + "@aztec/foundation": "v4.2.0-aztecnr-rc.2", + "@aztec/noir-contracts.js": "v4.2.0-aztecnr-rc.2", + "@aztec/protocol-contracts": "v4.2.0-aztecnr-rc.2", + "@aztec/pxe": "v4.2.0-aztecnr-rc.2", + "@aztec/stdlib": "v4.2.0-aztecnr-rc.2", + "@aztec/wallet-sdk": "v4.2.0-aztecnr-rc.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@gregojuice/contracts": "^0.0.10", + "@gregojuice/embedded-wallet": "^0.0.10", "@mui/icons-material": "^6.3.1", "@mui/material": "^6.3.1", "@mui/styles": "^6.3.1", @@ -45,7 +51,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@aztec/wallets": "v4.0.0-devnet.2-patch.3", + "@aztec/wallets": "v4.2.0-aztecnr-rc.2", "@eslint/js": "^9.18.0", "@playwright/test": "1.49.0", "@types/buffer-json": "^2", diff --git a/scripts/add-subscription-fpc.ts b/scripts/add-subscription-fpc.ts new file mode 100644 index 0000000..ea7b893 --- /dev/null +++ b/scripts/add-subscription-fpc.ts @@ -0,0 +1,86 @@ +/** + * Adds subscriptionFPC config to a network config file. + * + * Usage: + * node --experimental-transform-types scripts/add-subscription-fpc.ts \ + * --network testnet \ + * --fpc-address 0x... \ + * --fpc-secret 0x... + * + * This computes the function selectors for: + * - check_password_and_mint on the PoP contract + * - swap_tokens_for_exact_tokens_from on the AMM contract + * and writes them to the network config with configIndex=0. + */ + +import fs from 'fs'; +import path from 'path'; +import { FunctionSelector } from '@aztec/stdlib/abi'; + +import { ProofOfPasswordContractArtifact } from '../contracts/target/ProofOfPassword.ts'; +import { AMMContractArtifact } from '../contracts/target/AMM.ts'; + +function getArgs() { + const args = process.argv.slice(2); + const get = (name: string): string => { + const idx = args.indexOf(name); + if (idx === -1 || idx === args.length - 1) { + console.error(`Missing ${name}`); + process.exit(1); + } + return args[idx + 1]; + }; + return { + network: get('--network'), + fpcAddress: get('--fpc-address'), + fpcSecret: get('--fpc-secret'), + }; +} + +async function main() { + const { network, fpcAddress, fpcSecret } = getArgs(); + + const configPath = path.join(import.meta.dirname, '../src/config/networks', `${network}.json`); + if (!fs.existsSync(configPath)) { + console.error(`Network config not found: ${configPath}`); + process.exit(1); + } + + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + + // Compute selectors from artifacts + const popFn = ProofOfPasswordContractArtifact.functions.find(f => f.name === 'check_password_and_mint'); + if (!popFn) throw new Error('check_password_and_mint not found in ProofOfPassword artifact'); + const popSelector = await FunctionSelector.fromNameAndParameters(popFn.name, popFn.parameters); + + const ammFn = AMMContractArtifact.functions.find(f => f.name === 'swap_tokens_for_exact_tokens_from'); + if (!ammFn) throw new Error('swap_tokens_for_exact_tokens_from not found in AMM artifact'); + const ammSelector = await FunctionSelector.fromNameAndParameters(ammFn.name, ammFn.parameters); + + console.log(`PoP contract: ${config.contracts.pop}`); + console.log(` check_password_and_mint selector: ${popSelector.toString()}`); + console.log(`AMM contract: ${config.contracts.amm}`); + console.log(` swap_tokens_for_exact_tokens_from selector: ${ammSelector.toString()}`); + + // Build the subscriptionFPC config + config.subscriptionFPC = { + address: fpcAddress, + secretKey: fpcSecret, + functions: { + [config.contracts.pop]: { + [popSelector.toString()]: 0, + }, + [config.contracts.amm]: { + [ammSelector.toString()]: 0, + }, + }, + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log(`\nUpdated ${configPath} with subscriptionFPC config.`); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 0e7d57f..b3407fb 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -1,141 +1,90 @@ -import { SPONSORED_FPC_SALT } from '@aztec/constants'; -import { SponsoredFPCContractArtifact } from '@aztec/noir-contracts.js/SponsoredFPC'; -import { getPXEConfig } from '@aztec/pxe/server'; -import { EmbeddedWallet } from '@aztec/wallets/embedded'; import fs from 'fs'; import path from 'path'; import { TokenContract } from '@aztec/noir-contracts.js/Token'; -import { AMMContract } from '@aztec/noir-contracts.js/AMM'; -import { deriveSigningKey } from '@aztec/stdlib/keys'; +import { AMMContract } from '../contracts/target/AMM.ts'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { createAztecNodeClient, type AztecNode } from '@aztec/aztec.js/node'; -import { getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; +import type { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; +import type { EmbeddedWallet } from '@aztec/wallets/embedded'; import { ProofOfPasswordContract } from '../contracts/target/ProofOfPassword.ts'; -import { createLogger } from '@aztec/foundation/log'; import { BatchCall } from '@aztec/aztec.js/contracts'; -// Parse network from CLI args (--network ) -function getNetworkFromArgs(): string { - const args = process.argv.slice(2); - const networkIndex = args.indexOf('--network'); - if (networkIndex === -1 || networkIndex === args.length - 1) { - console.error('Usage: node deploy.ts --network '); - process.exit(1); - } - const network = args[networkIndex + 1]; - if (!['local', 'devnet', 'nextnet'].includes(network)) { - console.error(`Invalid network: ${network}. Must be 'local', 'devnet' or 'nextnet'`); - process.exit(1); - } - return network; -} - -const NETWORK = getNetworkFromArgs(); - -// Network-specific node URLs (hardcoded, not configurable) -const NETWORK_URLS: Record = { - local: 'http://localhost:8080', - devnet: 'https://v4-devnet-2.aztec-labs.com', - nextnet: 'https://nextnet.aztec-labs.com', -}; +import { + parseNetwork, + parseAddressList, + NETWORK_URLS, + setupWallet, + getOrCreateDeployer, +} from './utils.ts'; +const NETWORK = parseNetwork(); +const MINT_TO_ADDRESSES = parseAddressList('--mint-to', 'MINT_TO'); const AZTEC_NODE_URL = NETWORK_URLS[NETWORK]; -const PROVER_ENABLED = NETWORK !== 'local'; // Disable prover for local to speed up deployment - -const PASSWORD = process.env.PASSWORD ? process.env.PASSWORD : undefined; +const PASSWORD = process.env.PASSWORD; if (!PASSWORD) { throw new Error('Please specify a PASSWORD'); } -const PXE_STORE_DIR = path.join(import.meta.dirname, '.pxe-store'); - const INITIAL_TOKEN_BALANCE = 1_000_000_000n; -async function setupWallet(aztecNode: AztecNode) { - return await EmbeddedWallet.create(aztecNode, { - ephemeral: true, - pxeConfig: { ...getPXEConfig(), proverEnabled: PROVER_ENABLED }, - }); -} - -async function getSponsoredPFCContract() { - const instance = await getContractInstanceFromInstantiationParams(SponsoredFPCContractArtifact, { - salt: new Fr(SPONSORED_FPC_SALT), - }); - - return instance; -} - -async function createAccount(wallet: EmbeddedWallet) { - const salt = Fr.random(); - const secretKey = Fr.random(); - const signingKey = deriveSigningKey(secretKey); - const accountManager = await wallet.createSchnorrAccount(secretKey, salt, signingKey); - - const deployMethod = await accountManager.getDeployMethod(); - const sponsoredPFCContract = await getSponsoredPFCContract(); - const paymentMethod = new SponsoredFeePaymentMethod(sponsoredPFCContract.address); - const deployOpts = { - from: AztecAddress.ZERO, - fee: { - paymentMethod, - }, - skipClassPublication: true, - skipInstancePublication: true, - wait: { timeout: 120 }, - }; - await deployMethod.send(deployOpts); - - return { - address: accountManager.address, - salt, - secretKey, - }; -} - -async function deployContracts(wallet: EmbeddedWallet, deployer: AztecAddress) { - const sponsoredPFCContract = await getSponsoredPFCContract(); - const paymentMethod = new SponsoredFeePaymentMethod(sponsoredPFCContract.address); - +async function deployContracts( + wallet: EmbeddedWallet, + deployer: AztecAddress, + paymentMethod?: SponsoredFeePaymentMethod, +) { const contractAddressSalt = Fr.random(); - const gregoCoin = await TokenContract.deploy(wallet, deployer, 'GregoCoin', 'GRG', 18).send({ + const { contract: gregoCoin } = await TokenContract.deploy(wallet, deployer, 'GregoCoin', 'GRG', 18).send({ from: deployer, fee: { paymentMethod }, contractAddressSalt, wait: { timeout: 120 }, }); - const gregoCoinPremium = await TokenContract.deploy(wallet, deployer, 'GregoCoinPremium', 'GRGP', 18).send({ + const { contract: gregoCoinPremium } = await TokenContract.deploy( + wallet, + deployer, + 'GregoCoinPremium', + 'GRGP', + 18, + ).send({ from: deployer, fee: { paymentMethod }, contractAddressSalt, wait: { timeout: 120 }, }); - const liquidityToken = await TokenContract.deploy(wallet, deployer, 'LiquidityToken', 'LQT', 18).send({ + const { contract: liquidityToken } = await TokenContract.deploy(wallet, deployer, 'LiquidityToken', 'LQT', 18).send({ from: deployer, fee: { paymentMethod }, contractAddressSalt, wait: { timeout: 120 }, }); - const amm = await AMMContract.deploy( + const { contract: amm } = await AMMContract.deploy( wallet, gregoCoin.address, gregoCoinPremium.address, liquidityToken.address, ).send({ from: deployer, fee: { paymentMethod }, contractAddressSalt, wait: { timeout: 120 } }); + const extraMints = MINT_TO_ADDRESSES.flatMap(addr => { + const recipient = AztecAddress.fromString(addr); + console.log(`Will mint ${INITIAL_TOKEN_BALANCE} GregoCoin + GregoCoinPremium to ${addr}`); + return [ + gregoCoin.methods.mint_to_private(recipient, INITIAL_TOKEN_BALANCE), + gregoCoinPremium.methods.mint_to_private(recipient, INITIAL_TOKEN_BALANCE), + ]; + }); + await new BatchCall(wallet, [ liquidityToken.methods.set_minter(amm.address, true), gregoCoin.methods.mint_to_private(deployer, INITIAL_TOKEN_BALANCE), gregoCoinPremium.methods.mint_to_private(deployer, INITIAL_TOKEN_BALANCE), + ...extraMints, ]).send({ from: deployer, fee: { paymentMethod }, wait: { timeout: 120 } }); const nonceForAuthwits = Fr.random(); @@ -199,12 +148,11 @@ async function deployContracts(wallet: EmbeddedWallet, deployer: AztecAddress) { liquidityTokenAddress: liquidityToken.address.toString(), ammAddress: amm.address.toString(), popAddress: pop.address.toString(), - sponsoredFPCAddress: sponsoredPFCContract.address, contractAddressSalt: contractAddressSalt.toString(), }; } -async function writeNetworkConfig(network: string, deploymentInfo: any) { +async function writeNetworkConfig(network: string, deploymentInfo: any, sponsoredFPCAddress: string) { const configDir = path.join(import.meta.dirname, '../src/config/networks'); fs.mkdirSync(configDir, { recursive: true }); @@ -220,7 +168,7 @@ async function writeNetworkConfig(network: string, deploymentInfo: any) { amm: deploymentInfo.ammAddress, liquidityToken: deploymentInfo.liquidityTokenAddress, pop: deploymentInfo.popAddress, - sponsoredFPC: deploymentInfo.sponsoredFPCAddress, + sponsoredFPC: sponsoredFPCAddress, salt: deploymentInfo.contractAddressSalt, }, deployer: { @@ -248,20 +196,14 @@ async function writeNetworkConfig(network: string, deploymentInfo: any) { `); } -async function createAccountAndDeployContract() { - const aztecNode = createAztecNodeClient(AZTEC_NODE_URL); - const wallet = await setupWallet(aztecNode); - - const { rollupVersion, l1ChainId: chainId } = await aztecNode.getNodeInfo(); +async function main() { + const { node, wallet, paymentMethod, sponsoredFPC } = await setupWallet(AZTEC_NODE_URL, NETWORK); - // Register the SponsoredFPC contract (for sponsored fee payments) - await wallet.registerContract(await getSponsoredPFCContract(), SponsoredFPCContractArtifact); + const { rollupVersion, l1ChainId: chainId } = await node.getNodeInfo(); - // Create a new account - const { address: deployer } = await createAccount(wallet); + const deployer = await getOrCreateDeployer(wallet, paymentMethod); - // Deploy the contract - const contractDeploymentInfo = await deployContracts(wallet, deployer); + const contractDeploymentInfo = await deployContracts(wallet, deployer, paymentMethod); const deploymentInfo = { ...contractDeploymentInfo, chainId: chainId.toString(), @@ -269,17 +211,12 @@ async function createAccountAndDeployContract() { deployerAddress: deployer.toString(), }; - // Save the network config to src/config/networks/ - await writeNetworkConfig(NETWORK, deploymentInfo); + await writeNetworkConfig(NETWORK, deploymentInfo, sponsoredFPC.address.toString()); - // Clean up the PXE store - fs.rmSync(PXE_STORE_DIR, { recursive: true, force: true }); process.exit(0); } -createAccountAndDeployContract().catch(error => { +main().catch(error => { console.error(error); process.exit(1); }); - -export { createAccountAndDeployContract }; diff --git a/scripts/mint.ts b/scripts/mint.ts new file mode 100644 index 0000000..c90e8e2 --- /dev/null +++ b/scripts/mint.ts @@ -0,0 +1,103 @@ +/** + * Mint tokens to one or more addresses on an existing deployment. + * + * Usage: + * SECRET=0x... node --experimental-transform-types scripts/mint.ts --network testnet --to 0xaddr1 --to 0xaddr2 + * SECRET=0x... MINT_TO=0xaddr1,0xaddr2 node --experimental-transform-types scripts/mint.ts --network testnet + * + * Requires SECRET env var to reconstruct the deployer account (must match the original deployer). + */ + +import fs from 'fs'; +import path from 'path'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { TokenContract } from '@aztec/noir-contracts.js/Token'; +import { BatchCall } from '@aztec/aztec.js/contracts'; +import { parseNetwork, parseAddressList, NETWORK_URLS, setupWallet, getOrCreateDeployer } from './utils.ts'; + +const NETWORK = parseNetwork(); +const MINT_TO = parseAddressList('--to', 'MINT_TO'); + +if (MINT_TO.length === 0) { + console.error('No addresses to mint to. Use --to
or MINT_TO env var.'); + process.exit(1); +} + +if (!process.env.SECRET) { + console.error('SECRET env var is required to reconstruct the deployer account.'); + process.exit(1); +} + +const AMOUNT = process.env.AMOUNT ? BigInt(process.env.AMOUNT) : 1_000_000_000n; + +// Load network config to get contract addresses +const configPath = path.join(import.meta.dirname, `../src/config/networks/${NETWORK}.json`); +if (!fs.existsSync(configPath)) { + console.error(`Network config not found: ${configPath}. Run deploy first.`); + process.exit(1); +} +const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + +async function main() { + const nodeUrl = NETWORK_URLS[NETWORK]; + const { node, wallet, paymentMethod } = await setupWallet(nodeUrl, NETWORK); + + console.log('Reconstructing deployer account...'); + const deployer = await getOrCreateDeployer(wallet, paymentMethod); + console.log(`Deployer: ${deployer.toString()}`); + + // Verify deployer matches config + if (deployer.toString() !== config.deployer.address) { + console.error(`Deployer mismatch! Expected ${config.deployer.address}, got ${deployer.toString()}`); + console.error('Make sure SECRET matches the original deployment.'); + process.exit(1); + } + + // Register token contracts + const gregoCoinAddress = AztecAddress.fromString(config.contracts.gregoCoin); + const gregoCoinPremiumAddress = AztecAddress.fromString(config.contracts.gregoCoinPremium); + + const { TokenContractArtifact } = await import('@aztec/noir-contracts.js/Token'); + const [gregoCoinInstance, gregoCoinPremiumInstance] = await Promise.all([ + wallet.getContractMetadata(gregoCoinAddress).then(m => m.instance), + wallet.getContractMetadata(gregoCoinPremiumAddress).then(m => m.instance), + ]); + + // Register if not already registered + if (!gregoCoinInstance) { + const instance = await node.getContract(gregoCoinAddress); + await wallet.registerContract(instance!, TokenContractArtifact); + } + if (!gregoCoinPremiumInstance) { + const instance = await node.getContract(gregoCoinPremiumAddress); + await wallet.registerContract(instance!, TokenContractArtifact); + } + + const gregoCoin = TokenContract.at(gregoCoinAddress, wallet); + const gregoCoinPremium = TokenContract.at(gregoCoinPremiumAddress, wallet); + + // Build mint calls + const mintCalls = MINT_TO.flatMap(addr => { + const recipient = AztecAddress.fromString(addr); + console.log(`Will mint ${AMOUNT} GregoCoin + GregoCoinPremium to ${addr}`); + return [ + gregoCoin.methods.mint_to_private(recipient, AMOUNT), + gregoCoinPremium.methods.mint_to_private(recipient, AMOUNT), + ]; + }); + + console.log(`Sending batch mint tx (${mintCalls.length} calls)...`); + await new BatchCall(wallet, mintCalls).send({ + from: deployer, + fee: { paymentMethod }, + wait: { timeout: 120 }, + }); + + console.log('Done! Tokens minted successfully.'); + process.exit(0); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/utils.ts b/scripts/utils.ts new file mode 100644 index 0000000..5e968c4 --- /dev/null +++ b/scripts/utils.ts @@ -0,0 +1,116 @@ +import { SPONSORED_FPC_SALT } from '@aztec/constants'; +import { SponsoredFPCContractArtifact } from '@aztec/noir-contracts.js/SponsoredFPC'; +import { getPXEConfig } from '@aztec/pxe/server'; +import { EmbeddedWallet } from '@aztec/wallets/embedded'; +import { deriveSigningKey } from '@aztec/stdlib/keys'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { createAztecNodeClient, type AztecNode } from '@aztec/aztec.js/node'; +import { getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; +import { NO_FROM } from '@aztec/aztec.js/account'; +import { ContractInitializationStatus } from '@aztec/aztec.js/wallet'; + +// ── Network configuration ──────────────────────────────────────────── + +export const VALID_NETWORKS = ['local', 'devnet', 'nextnet', 'testnet'] as const; +export type NetworkName = (typeof VALID_NETWORKS)[number]; + +export const NETWORK_URLS: Record = { + local: 'http://localhost:8080', + devnet: 'https://v4-devnet-2.aztec-labs.com', + nextnet: 'https://nextnet.aztec-labs.com', + testnet: 'https://rpc.testnet.aztec-labs.com', +}; + +// ── CLI parsing ────────────────────────────────────────────────────── + +export function parseNetwork(): NetworkName { + const args = process.argv.slice(2); + const networkIndex = args.indexOf('--network'); + if (networkIndex === -1 || networkIndex === args.length - 1) { + console.error(`Usage: ... --network <${VALID_NETWORKS.join('|')}>`); + process.exit(1); + } + const network = args[networkIndex + 1]; + if (!VALID_NETWORKS.includes(network as NetworkName)) { + console.error(`Invalid network: ${network}. Must be one of: ${VALID_NETWORKS.join(', ')}`); + process.exit(1); + } + return network as NetworkName; +} + +export function parseAddressList(flag: string, envVar?: string): string[] { + const args = process.argv.slice(2); + const addresses: string[] = []; + let i = 0; + while (i < args.length) { + if (args[i] === flag && i + 1 < args.length) { + addresses.push(args[i + 1]); + i += 2; + } else { + i++; + } + } + if (envVar && process.env[envVar]) { + addresses.push(...process.env[envVar]!.split(',').map(s => s.trim()).filter(Boolean)); + } + return addresses; +} + +// ── Wallet setup ───────────────────────────────────────────────────── + +export async function getSponsoredFPCContract() { + return getContractInstanceFromInstantiationParams(SponsoredFPCContractArtifact, { + salt: new Fr(SPONSORED_FPC_SALT), + }); +} + +export function getPaymentMethod(network: NetworkName, sponsoredFPCAddress: AztecAddress) { + return network !== 'testnet' ? new SponsoredFeePaymentMethod(sponsoredFPCAddress) : undefined; +} + +export async function setupWallet(nodeUrl: string, network: NetworkName) { + const node = createAztecNodeClient(nodeUrl); + const proverEnabled = network !== 'local'; + const wallet = await EmbeddedWallet.create(node, { + ephemeral: true, + pxeConfig: { ...getPXEConfig(), proverEnabled }, + }); + + const sponsoredFPC = await getSponsoredFPCContract(); + await wallet.registerContract(sponsoredFPC, SponsoredFPCContractArtifact); + + const paymentMethod = getPaymentMethod(network, sponsoredFPC.address); + + return { node, wallet, paymentMethod, sponsoredFPC }; +} + +/** + * Reconstructs the deployer account from SECRET env var (deterministic) + * or creates a new random one. Returns the address. + */ +export async function getOrCreateDeployer( + wallet: EmbeddedWallet, + paymentMethod?: SponsoredFeePaymentMethod, +): Promise { + const salt = new Fr(0); + const secretKey = process.env.SECRET ? Fr.fromString(process.env.SECRET) : await Fr.random(); + const signingKey = deriveSigningKey(secretKey); + const accountManager = await wallet.createSchnorrAccount(secretKey, salt, signingKey); + + const { initializationStatus } = await wallet.getContractMetadata(accountManager.address); + + if (initializationStatus !== ContractInitializationStatus.INITIALIZED) { + const deployMethod = await accountManager.getDeployMethod(); + await deployMethod.send({ + from: NO_FROM, + fee: { paymentMethod }, + skipClassPublication: true, + skipInstancePublication: true, + wait: { timeout: 120 }, + }); + } + + return accountManager.address; +} diff --git a/src/components/TxNotificationCenter.tsx b/src/components/TxNotificationCenter.tsx index 27de1b6..653a564 100644 --- a/src/components/TxNotificationCenter.tsx +++ b/src/components/TxNotificationCenter.tsx @@ -24,7 +24,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; -import { txProgress, type TxProgressEvent, type PhaseTiming } from '../tx-progress'; +import { txProgress, type TxProgressEvent, type PhaseTiming } from '@gregojuice/embedded-wallet'; // ─── Live phase support ─────────────────────────────────────────────────────── diff --git a/src/components/onboarding/FlowMessages.tsx b/src/components/onboarding/FlowMessages.tsx index fd2925e..367cc1a 100644 --- a/src/components/onboarding/FlowMessages.tsx +++ b/src/components/onboarding/FlowMessages.tsx @@ -13,26 +13,6 @@ interface FlowMessagesProps { } export function FlowMessages({ status, hasSimulationGrant, useEmbeddedWallet }: FlowMessagesProps) { - // Show message during account deployment - if (status === 'deploying_account') { - return ( - - - Deploying your account on-chain. This may take a moment... - - - ); - } - // Show message during simulation - different text based on whether grant was given if (status === 'simulating') { return ( diff --git a/src/config/capabilities.ts b/src/config/capabilities.ts index b8d32a7..3751622 100644 --- a/src/config/capabilities.ts +++ b/src/config/capabilities.ts @@ -30,33 +30,47 @@ export function createGregoSwapCapabilities(network: NetworkConfig): AppCapabili const gregoCoinPremiumAddress = AztecAddress.fromString(network.contracts.gregoCoinPremium); const ammAddress = AztecAddress.fromString(network.contracts.amm); const popAddress = AztecAddress.fromString(network.contracts.pop); - const sponsoredFPCAddress = AztecAddress.fromString(network.contracts.sponsoredFPC); - // Specific contract addresses for registration - const contractAddresses = [ammAddress, gregoCoinAddress, gregoCoinPremiumAddress, popAddress, sponsoredFPCAddress]; + // All contracts that need registration + const contractAddresses = [ammAddress, gregoCoinAddress, gregoCoinPremiumAddress, popAddress]; - // Simulation patterns: specific contracts and functions + // Include subscription FPC if configured + const hasSubFPC = !!network.subscriptionFPC; + if (hasSubFPC) { + contractAddresses.push(AztecAddress.fromString(network.subscriptionFPC!.address)); + } + + // Simulation patterns const txSimulationPatterns: ContractFunctionPattern[] = [ - // Balance queries for exchange rate (public balances) { contract: gregoCoinAddress, function: 'balance_of_public' }, { contract: gregoCoinPremiumAddress, function: 'balance_of_public' }, ]; const utilitySimulationPatterns: ContractFunctionPattern[] = [ - // Balance queries for user (private balances) { contract: gregoCoinAddress, function: 'balance_of_private' }, { contract: gregoCoinPremiumAddress, function: 'balance_of_private' }, ]; - // Transaction patterns: specific contracts and functions + // Transaction patterns const transactionPatterns: ContractFunctionPattern[] = [ - // Swap transaction { contract: ammAddress, function: 'swap_tokens_for_exact_tokens' }, - - // Drip transaction (ProofOfPassword) { contract: popAddress, function: 'check_password_and_mint' }, ]; + // Subscription FPC: the user calls subscribe/sponsor which internally dispatch + // the sponsored call + auth witnesses + if (hasSubFPC) { + const fpcAddress = AztecAddress.fromString(network.subscriptionFPC!.address); + transactionPatterns.push( + { contract: fpcAddress, function: 'subscribe' }, + { contract: fpcAddress, function: 'sponsor' }, + ); + // The _from variant of the swap is called by the FPC on behalf of the user + transactionPatterns.push( + { contract: ammAddress, function: 'swap_tokens_for_exact_tokens_from' }, + ); + } + return { version: '1.0', metadata: { diff --git a/src/config/networks/devnet.json b/src/config/networks/devnet.json deleted file mode 100644 index 14ebc9a..0000000 --- a/src/config/networks/devnet.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "devnet", - "nodeUrl": "https://v4-devnet-2.aztec-labs.com", - "chainId": "11155111", - "rollupVersion": "615022430", - "contracts": { - "gregoCoin": "0x0299fbace3b22cb92f77105cf0858fa28cb81fdde8224bd9f547103b62549015", - "gregoCoinPremium": "0x2c183370da7ba6ec38eae88e741d3784924f3705e83fd21db38f22b8b35d4761", - "amm": "0x0ca83d33b850faf48c9a885d9a832b87bb8f48ad71821e3fa758e9fb6779e7fd", - "liquidityToken": "0x2274ec5c0584a41ba8337aa4a806028fa09ea9c4c141d9de1c2d14eda6913105", - "pop": "0x1674809b55029e57a751be1db39e7cef89ca07b986f6bd270f7537fcb9215670", - "sponsoredFPC": "0x09a4df73aa47f82531a038d1d51abfc85b27665c4b7ca751e2d4fa9f19caffb2", - "salt": "0x20dbf2773747cdd5ab17f6269f0cda5a0e6a8ce54a594a00a26782af0efc18cb" - }, - "deployer": { - "address": "0x0b96e41ab877b4444a6950dc38e16f7f283aad02a440f60e7a2e9afe9c3a8a6f" - }, - "deployedAt": "2026-02-20T08:20:14.254Z" -} \ No newline at end of file diff --git a/src/config/networks/index.ts b/src/config/networks/index.ts index 86ac75c..f06330d 100644 --- a/src/config/networks/index.ts +++ b/src/config/networks/index.ts @@ -1,3 +1,12 @@ +export interface SubscriptionFPCConfig { + /** Address of the SubscriptionFPC contract */ + address: string; + /** Secret key for registering the FPC in PXE (needed to decrypt slot notes) */ + secretKey: string; + /** Map of contractAddress → { functionSelector → configIndex } */ + functions: Record>; +} + export interface NetworkConfig { id: string; nodeUrl: string; @@ -16,6 +25,8 @@ export interface NetworkConfig { address: string; }; deployedAt: string; + /** Subscription-based FPC for sponsored transactions (operator-managed) */ + subscriptionFPC?: SubscriptionFPCConfig; } // Load all network configs using Vite's glob import with eager loading diff --git a/src/config/networks/nextnet.json b/src/config/networks/nextnet.json deleted file mode 100644 index 5c0c751..0000000 --- a/src/config/networks/nextnet.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "nextnet", - "nodeUrl": "https://nextnet.aztec-labs.com", - "chainId": "11155111", - "rollupVersion": "1537762289", - "contracts": { - "gregoCoin": "0x0a8153bfbefd66ee0889fe905aad9eedb9cb3b1b1778efb90a9e757df9580851", - "gregoCoinPremium": "0x1a76545b92c7c9c6603ea4935830f92a6f2c0ef2c69123ff2aad297762b056d5", - "amm": "0x1c802c97bc717601040b480955ac344ed20cd3f1ddc2f44d06d3d3f97000ac0f", - "liquidityToken": "0x28b7e4606b1b534dbc70290acee13c017c33a82f2b2ffbdde36b43976329cd49", - "pop": "0x18e25a54b301404e393a2439fcc1f7b042a9cdbe4d294b2df7b6c88efc1f8e40", - "sponsoredFPC": "0x144de396522291dd55b16bd078f14091e6373bbf82a6ea496bda84ad6b4860e7", - "salt": "0x2e9070a6c98a527034502d27b1d947b115cfabbc4f25de37f0567ce74236d949" - }, - "deployer": { - "address": "0x1ce7859a518557348fed766077d3984d175dda731eacd1166b08789d6c53fd07" - }, - "deployedAt": "2026-02-09T09:30:42.751Z" -} \ No newline at end of file diff --git a/src/config/networks/testnet.json b/src/config/networks/testnet.json new file mode 100644 index 0000000..265602e --- /dev/null +++ b/src/config/networks/testnet.json @@ -0,0 +1,31 @@ +{ + "id": "testnet", + "nodeUrl": "https://rpc.testnet.aztec-labs.com", + "chainId": "11155111", + "rollupVersion": "4127419662", + "contracts": { + "gregoCoin": "0x2067afddd4a85b77727581437e2a9209a70d84b6f56cb8fff7de904b21a1b3d8", + "gregoCoinPremium": "0x08ea2e42729a62692b118a4037fadc739908100624d88881c65c0a32098dd522", + "amm": "0x00896f509f72d1cf7b001e169a80f6ce27be1bd68fffb76be92a31258185b007", + "liquidityToken": "0x268496b99c091f3e853a6d06dea8a7a170f682327ac7e439bd147bbbe5745278", + "pop": "0x1633810e36969a20535577f0c477a6d17d74a5f562a5d287fb68bb5ccb2f3261", + "sponsoredFPC": "0x2ae02a54fd254586fd628ff46b71071bd8db32b63dc5d083f844f2c208a3923c", + "salt": "0x22cc7f6fbe8454271332523f9888939e4847920f428c3f3cc75b8404ddab3eef" + }, + "deployer": { + "address": "0x0791b66994f06b5e54b4862d7b2a3f6549c4fa1a44f915dde173d096d4bf34b3" + }, + "deployedAt": "2026-03-27T12:46:03.453Z", + "subscriptionFPC": { + "address": "0x2ef4be56d83d448d37909ede8ac3a4ac69daab309584a1bceebbc9f0639f825c", + "secretKey": "0x170b161f66a974fb1a0a82f43d0b90e6a2e7d43936c8bdab8aaf385c791ef58c", + "functions": { + "0x1633810e36969a20535577f0c477a6d17d74a5f562a5d287fb68bb5ccb2f3261": { + "0xa539bd29": 0 + }, + "0x00896f509f72d1cf7b001e169a80f6ce27be1bd68fffb76be92a31258185b007": { + "0xfd228669": 0 + } + } + } +} diff --git a/src/contexts/contracts/ContractsContext.tsx b/src/contexts/contracts/ContractsContext.tsx index 6b92bd5..f4d090c 100644 --- a/src/contexts/contracts/ContractsContext.tsx +++ b/src/contexts/contracts/ContractsContext.tsx @@ -6,7 +6,6 @@ import { createContext, useContext, useEffect, type ReactNode, useCallback } from 'react'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; import type { TxReceipt } from '@aztec/stdlib/tx'; -import { Fr } from '@aztec/aztec.js/fields'; import { useWallet } from '../wallet'; import { useNetwork } from '../network'; import * as contractService from '../../services/contractService'; @@ -83,7 +82,13 @@ export function ContractsProvider({ children }: ContractsProviderProps) { // Get exchange rate const getExchangeRate = useCallback(async (): Promise => { - if (!wallet || !currentAddress || !state.contracts.amm || !state.contracts.gregoCoin || !state.contracts.gregoCoinPremium) { + if ( + !wallet || + !currentAddress || + !state.contracts.amm || + !state.contracts.gregoCoin || + !state.contracts.gregoCoinPremium + ) { throw new Error('Contracts not initialized'); } @@ -111,19 +116,18 @@ export function ContractsProvider({ children }: ContractsProviderProps) { throw new Error('Contracts not initialized'); } - const authwitNonce = Fr.random(); - - return state.contracts.amm.methods - .swap_tokens_for_exact_tokens( - state.contracts.gregoCoin.address, - state.contracts.gregoCoinPremium.address, - BigInt(Math.round(amountOut)), - BigInt(Math.round(amountInMax)), - authwitNonce, - ) - .send({ from: currentAddress }); + return contractService.executeSponsoredSwap( + wallet, + activeNetwork, + state.contracts.amm, + state.contracts.gregoCoin, + state.contracts.gregoCoinPremium, + currentAddress, + amountOut, + amountInMax, + ); }, - [wallet, currentAddress, state.contracts], + [wallet, currentAddress, activeNetwork, state.contracts], ); // Fetch balances @@ -171,13 +175,13 @@ export function ContractsProvider({ children }: ContractsProviderProps) { // Execute drip const drip = useCallback( async (password: string, recipient: AztecAddress): Promise => { - if (!state.contracts.pop) { + if (!wallet || !node || !state.contracts.pop) { throw new Error('ProofOfPassword contract not initialized'); } - return contractService.executeDrip(state.contracts.pop, password, recipient); + return contractService.executeDrip(wallet, activeNetwork, state.contracts.pop, password, recipient); }, - [state.contracts.pop], + [wallet, activeNetwork, state.contracts.pop], ); // Initialize contracts for embedded wallet diff --git a/src/contexts/contracts/reducer.ts b/src/contexts/contracts/reducer.ts index 4cc7ccb..2b44b62 100644 --- a/src/contexts/contracts/reducer.ts +++ b/src/contexts/contracts/reducer.ts @@ -4,7 +4,7 @@ */ import type { TokenContract } from '@aztec/noir-contracts.js/Token'; -import type { AMMContract } from '@aztec/noir-contracts.js/AMM'; +import type { AMMContract } from '../../../contracts/target/AMM'; import type { ProofOfPasswordContract } from '../../../contracts/target/ProofOfPassword'; import { createReducerHook, type ActionsFrom } from '../utils'; diff --git a/src/contexts/onboarding/OnboardingContext.tsx b/src/contexts/onboarding/OnboardingContext.tsx index 752719b..fa6ed1a 100644 --- a/src/contexts/onboarding/OnboardingContext.tsx +++ b/src/contexts/onboarding/OnboardingContext.tsx @@ -21,8 +21,6 @@ import { type DripPhase, } from './reducer'; import { parseDripError } from '../../services/contractService'; -import { deployEmbeddedAccount } from '../../services/walletService'; -import { EmbeddedWallet } from '../../embedded_wallet'; export type { OnboardingStatus, OnboardingStep }; export { ONBOARDING_STEPS, ONBOARDING_STEPS_WITH_DRIP, getOnboardingSteps, getOnboardingStepsWithDrip }; @@ -109,7 +107,7 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { ? getOnboardingStepsWithDrip(state.hasSimulationGrant, state.useEmbeddedWallet) : getOnboardingSteps(state.hasSimulationGrant, state.useEmbeddedWallet); const currentStep = calculateCurrentStep(state.status, state.needsDrip, state.useEmbeddedWallet); - const baseSteps = state.useEmbeddedWallet ? 5 : 4; + const baseSteps = steps.length; const totalSteps = state.needsDrip ? baseSteps + 1 : baseSteps; const isSwapPending = state.status === 'completed' && state.pendingSwap; const isDripPending = state.status === 'executing_drip' && state.dripPassword !== null; @@ -133,19 +131,14 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { await registerBaseContracts(); } - // Step 1b: After embedded wallet selection, deploy account if needed + // Step 1b: For embedded wallet, register contracts when entering 'registering' status if ( - state.status === 'deploying_account' && + state.status === 'registering' && currentAddress && - wallet && - node && - !state.hasDeployedAccount + isUsingEmbeddedWallet && + !state.hasRegisteredBase ) { - actions.markDeployedAccount(); - await deployEmbeddedAccount(wallet as EmbeddedWallet); - // After deployment, proceed to register contracts actions.markRegistered(); - actions.advanceStatus('registering'); await registerBaseContracts(); } @@ -189,7 +182,6 @@ export function OnboardingProvider({ children }: OnboardingProviderProps) { state.status, state.hasRegisteredBase, state.hasSimulated, - state.hasDeployedAccount, state.useEmbeddedWallet, currentAddress, isUsingEmbeddedWallet, diff --git a/src/contexts/onboarding/reducer.ts b/src/contexts/onboarding/reducer.ts index 131f4e1..94545d2 100644 --- a/src/contexts/onboarding/reducer.ts +++ b/src/contexts/onboarding/reducer.ts @@ -12,7 +12,6 @@ import { createReducerHook, type ActionsFrom } from '../utils'; export type OnboardingStatus = | 'idle' | 'connecting' - | 'deploying_account' | 'registering' | 'simulating' | 'registering_drip' @@ -45,7 +44,6 @@ export interface OnboardingState { error: string | null; hasRegisteredBase: boolean; hasSimulated: boolean; - hasDeployedAccount: boolean; needsDrip: boolean; dripPhase: DripPhase; dripError: string | null; @@ -64,7 +62,6 @@ export const initialOnboardingState: OnboardingState = { error: null, hasRegisteredBase: false, hasSimulated: false, - hasDeployedAccount: false, needsDrip: false, dripPhase: 'idle', dripError: null, @@ -83,7 +80,6 @@ export const onboardingActions = { setPassword: (password: string) => ({ type: 'onboarding/SET_PASSWORD' as const, password }), markRegistered: () => ({ type: 'onboarding/MARK_REGISTERED' as const }), markSimulated: () => ({ type: 'onboarding/MARK_SIMULATED' as const }), - markDeployedAccount: () => ({ type: 'onboarding/MARK_DEPLOYED_ACCOUNT' as const }), markNeedsDrip: () => ({ type: 'onboarding/MARK_NEEDS_DRIP' as const }), selectEmbeddedWallet: () => ({ type: 'onboarding/SELECT_EMBEDDED_WALLET' as const }), setSimulationGrant: (granted: boolean) => ({ type: 'onboarding/SET_SIMULATION_GRANT' as const, granted }), @@ -134,14 +130,11 @@ export function onboardingReducer(state: OnboardingState, action: OnboardingActi case 'onboarding/MARK_SIMULATED': return { ...state, hasSimulated: true }; - case 'onboarding/MARK_DEPLOYED_ACCOUNT': - return { ...state, hasDeployedAccount: true }; - case 'onboarding/MARK_NEEDS_DRIP': return { ...state, needsDrip: true, pendingSwap: false }; case 'onboarding/SELECT_EMBEDDED_WALLET': - return { ...state, useEmbeddedWallet: true, status: 'deploying_account' }; + return { ...state, useEmbeddedWallet: true, status: 'registering' }; case 'onboarding/SET_SIMULATION_GRANT': return { ...state, hasSimulationGrant: action.granted }; @@ -185,24 +178,21 @@ export function onboardingReducer(state: OnboardingState, action: OnboardingActi export function calculateCurrentStep(status: OnboardingStatus, needsDrip: boolean, useEmbeddedWallet: boolean): number { if (useEmbeddedWallet) { + // Steps are 1-indexed to match OnboardingProgress (stepNum = index + 1) switch (status) { case 'idle': return 0; case 'connecting': - return 1; - case 'deploying_account': - return 2; case 'registering': - return 3; case 'simulating': - return 4; + return 2; case 'registering_drip': - return 4; + return 3; case 'awaiting_drip': case 'executing_drip': - return 5; + return 4; case 'completed': - return needsDrip ? 6 : 5; + return needsDrip ? 5 : 4; default: return 0; } @@ -234,10 +224,6 @@ export function getOnboardingSteps(hasSimulationGrant: boolean, useEmbeddedWalle { label: 'Choose Wallet', description: 'Select how you want to connect' }, ]; - if (useEmbeddedWallet) { - steps.push({ label: 'Deploy Account', description: 'Deploying your account on-chain' }); - } - steps.push({ label: 'Register Contracts', description: 'Registering any missing contracts' }); if (useEmbeddedWallet || hasSimulationGrant) { @@ -254,10 +240,6 @@ export function getOnboardingStepsWithDrip(hasSimulationGrant: boolean, useEmbed { label: 'Choose Wallet', description: 'Select how you want to connect' }, ]; - if (useEmbeddedWallet) { - steps.push({ label: 'Deploy Account', description: 'Deploying your account on-chain' }); - } - steps.push( { label: 'Register Contracts', description: 'Registering any missing contracts' }, { label: 'Register Faucet', description: 'Registering the token faucet contract if needed' }, diff --git a/src/embedded_wallet.ts b/src/embedded_wallet.ts deleted file mode 100644 index cccdfac..0000000 --- a/src/embedded_wallet.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { collectOffchainEffects, type ExecutionPayload } from '@aztec/stdlib/tx'; -import { AccountFeePaymentMethodOptions } from '@aztec/entrypoints/account'; -import type { AztecNode } from '@aztec/aztec.js/node'; -import { type InteractionWaitOptions, NO_WAIT, type SendReturn } from '@aztec/aztec.js/contracts'; -import { waitForTx } from '@aztec/aztec.js/node'; -import type { SendOptions } from '@aztec/aztec.js/wallet'; -import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; -import { CallAuthorizationRequest } from '@aztec/aztec.js/authorization'; -import { type FeeOptions } from '@aztec/wallet-sdk/base-wallet'; -import { txProgress, type PhaseTiming, type TxProgressEvent } from './tx-progress'; -import type { FieldsOf } from '@aztec/foundation/types'; -import { GasSettings } from '@aztec/stdlib/gas'; -import { getSponsoredFPCData } from './services'; -import { EmbeddedWallet as EmbeddedWalletBase, type EmbeddedWalletOptions } from '@aztec/wallets/embedded'; -import { AccountManager } from '@aztec/aztec.js/wallet'; -import { Fr } from '@aztec/foundation/curves/bn254'; - -export class EmbeddedWallet extends EmbeddedWalletBase { - private skipAuthWitExtraction = false; - - static override create( - nodeOrUrl: string | AztecNode, - options?: EmbeddedWalletOptions, - ): Promise { - return super.create(nodeOrUrl, options); - } - - /** - * Returns the AccountManager for the first stored account, creating a new Schnorr - * account (with random credentials) if none exist yet. The account is persisted in - * the embedded wallet's internal DB, so the same address is restored on subsequent loads. - */ - async getOrCreateAccount(): Promise { - const existing = await this.getAccounts(); - if (existing.length > 0) { - const { secretKey, salt, signingKey, type } = await this.walletDB.retrieveAccount(existing[0].item); - return this.createAccountInternal(type, secretKey, salt, signingKey); - } - return this.createSchnorrAccount(Fr.random(), Fr.random(), undefined, 'main'); - } - - async isAccountDeployed(): Promise { - const [account] = await this.getAccounts(); - if (!account) { - return false; - } - const metadata = await this.getContractMetadata(account.item); - return metadata.isContractInitialized; - } - - async deployAccount() { - const accountManager = await this.getOrCreateAccount(); - - const { instance: sponsoredFPCInstance, artifact: SponsoredFPCContractArtifact } = await getSponsoredFPCData(); - const sponsoredFPCMetadata = await this.getContractMetadata(sponsoredFPCInstance.address); - if (!sponsoredFPCMetadata.instance) { - await this.registerContract(sponsoredFPCInstance, SponsoredFPCContractArtifact); - } - - const deployMethod = await accountManager.getDeployMethod(); - this.skipAuthWitExtraction = true; - try { - return await deployMethod.send({ - from: AztecAddress.ZERO, - fee: { - paymentMethod: new SponsoredFeePaymentMethod(sponsoredFPCInstance.address), - }, - }); - } finally { - this.skipAuthWitExtraction = false; - } - } - - /** - * Completes partial user-provided fee options with wallet defaults. - * @param from - The address where the transaction is being sent from - * @param feePayer - The address paying for fees (if any fee payment method is embedded in the execution payload) - * @param gasSettings - User-provided partial gas settings - * @returns - Complete fee options that can be used to create a transaction execution request - */ - protected override async completeFeeOptions( - from: AztecAddress, - feePayer?: AztecAddress, - gasSettings?: Partial>, - ): Promise { - const maxFeesPerGas = - gasSettings?.maxFeesPerGas ?? (await this.aztecNode.getCurrentMinFees()).mul(1 + this.minFeePadding); - let accountFeePaymentMethodOptions; - let walletFeePaymentMethod; - // The transaction does not include a fee payment method, so we - // use the sponsoredFPC - if (!feePayer) { - accountFeePaymentMethodOptions = AccountFeePaymentMethodOptions.EXTERNAL; - const { instance } = await getSponsoredFPCData(); - walletFeePaymentMethod = new SponsoredFeePaymentMethod(instance.address); - } else { - // The transaction includes fee payment method, so we check if we are the fee payer for it - // (this can only happen if the embedded payment method is FeeJuiceWithClaim) - accountFeePaymentMethodOptions = from.equals(feePayer) - ? AccountFeePaymentMethodOptions.FEE_JUICE_WITH_CLAIM - : AccountFeePaymentMethodOptions.EXTERNAL; - } - const fullGasSettings: GasSettings = GasSettings.default({ ...gasSettings, maxFeesPerGas }); - this.log.debug(`Using L2 gas settings`, fullGasSettings); - return { - gasSettings: fullGasSettings, - walletFeePaymentMethod, - accountFeePaymentMethodOptions, - }; - } - - override async sendTx( - executionPayload: ExecutionPayload, - opts: SendOptions, - ): Promise> { - const txId = crypto.randomUUID(); - const startTime = Date.now(); - const phases: PhaseTiming[] = []; - - // Derive a human-readable label from the first meaningful call in the payload - // Skip fee payment methods (e.g. sponsor_unconditionally) to find the actual user call - const meaningfulCall = - executionPayload.calls?.find(c => c.name !== 'sponsor_unconditionally') ?? executionPayload.calls?.[0]; - const fnName = meaningfulCall?.name ?? 'Transaction'; - const label = - fnName === 'constructor' ? 'Deploy' : fnName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - - const emit = (phase: TxProgressEvent['phase'], extra?: Partial) => { - txProgress.emit({ - txId, - label, - phase, - startTime, - phaseStartTime: Date.now(), - phases: [...phases], - ...extra, - }); - }; - - try { - const feeOptions = await this.completeFeeOptions(opts.from, executionPayload.feePayer, opts.fee?.gasSettings); - if (!this.skipAuthWitExtraction) { - emit('simulating'); - const simulationStart = Date.now(); - - const simulationResult = await this.simulateViaEntrypoint( - executionPayload, - opts.from, - feeOptions, - this.scopesFor(opts.from), - true, - true, - ); - - const offchainEffects = collectOffchainEffects(simulationResult.privateExecutionResult); - const authWitnesses = await Promise.all( - offchainEffects.map(async effect => { - try { - const authRequest = await CallAuthorizationRequest.fromFields(effect.data); - return this.createAuthWit(opts.from, { - consumer: effect.contractAddress, - innerHash: authRequest.innerHash, - }); - } catch { - return undefined; // Not a CallAuthorizationRequest, skip - } - }), - ); - for (const wit of authWitnesses) { - if (wit) executionPayload.authWitnesses.push(wit); - } - - const simulationDuration = Date.now() - simulationStart; - - // Build breakdown and details from simulation stats - const simStats = simulationResult.stats; - const breakdown: Array<{ label: string; duration: number }> = []; - const details: string[] = []; - - if (simStats?.timings) { - const t = simStats.timings; - if (t.sync > 0) breakdown.push({ label: 'Sync', duration: t.sync }); - if (t.perFunction.length > 0) { - const witgenTotal = t.perFunction.reduce((sum, fn) => sum + fn.time, 0); - breakdown.push({ label: 'Private execution', duration: witgenTotal }); - for (const fn of t.perFunction) { - breakdown.push({ label: ` ${fn.functionName.split(':').pop() || fn.functionName}`, duration: fn.time }); - } - } - if (t.publicSimulation) breakdown.push({ label: 'Public simulation', duration: t.publicSimulation }); - if (t.unaccounted > 0) breakdown.push({ label: 'Other', duration: t.unaccounted }); - } - - if (simStats?.nodeRPCCalls?.roundTrips) { - const rt = simStats.nodeRPCCalls.roundTrips; - const fmt = (ms: number) => (ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`); - details.push(`${rt.roundTrips} RPC round-trips (${fmt(rt.totalBlockingTime)} blocking)`); - const MAX_METHODS_SHOWN = 3; - for (let i = 0; i < rt.roundTripDurations.length; i++) { - const allMethods = rt.roundTripMethods[i] ?? []; - const count = allMethods.length; - const shown = allMethods.slice(0, MAX_METHODS_SHOWN).join(', '); - const suffix = count > MAX_METHODS_SHOWN ? `, ... +${count - MAX_METHODS_SHOWN} more` : ''; - details.push(` #${i + 1}: ${fmt(rt.roundTripDurations[i])} — ${shown}${suffix} (${count} calls)`); - } - } - - phases.push({ - name: 'Simulation', - duration: simulationDuration, - color: '#ce93d8', - ...(breakdown.length > 0 && { breakdown }), - ...(details.length > 0 && { details }), - }); - } - - // --- PROVING --- - emit('proving'); - const provingStart = Date.now(); - - const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(executionPayload, opts.from, feeOptions); - const provenTx = await this.pxe.proveTx(txRequest, this.scopesFor(opts.from)); - - const provingDuration = Date.now() - provingStart; - - // Extract detailed stats from proving result if available - const stats = provenTx.stats; - if (stats?.timings) { - const t = stats.timings; - if (t.sync && t.sync > 0) phases.push({ name: 'Sync', duration: t.sync, color: '#90caf9' }); - if (t.perFunction?.length > 0) { - const witgenTotal = t.perFunction.reduce((sum: number, fn: { time: number }) => sum + fn.time, 0); - phases.push({ - name: 'Witgen', - duration: witgenTotal, - color: '#ffb74d', - breakdown: t.perFunction.map((fn: { functionName: string; time: number }) => ({ - label: fn.functionName.split(':').pop() || fn.functionName, - duration: fn.time, - })), - }); - } - if (t.proving && t.proving > 0) phases.push({ name: 'Proving', duration: t.proving, color: '#f48fb1' }); - if (t.unaccounted > 0) phases.push({ name: 'Other', duration: t.unaccounted, color: '#bdbdbd' }); - } else { - phases.push({ name: 'Proving', duration: provingDuration, color: '#f48fb1' }); - } - - // --- SENDING --- - emit('sending'); - const sendingStart = Date.now(); - - const tx = await provenTx.toTx(); - const txHash = tx.getTxHash(); - if (await this.aztecNode.getTxEffect(txHash)) { - throw new Error(`A settled tx with equal hash ${txHash.toString()} exists.`); - } - await this.aztecNode.sendTx(tx); - - const sendingDuration = Date.now() - sendingStart; - phases.push({ name: 'Sending', duration: sendingDuration, color: '#2196f3' }); - - // NO_WAIT: return txHash immediately - if (opts.wait === NO_WAIT) { - emit('complete'); - return txHash as SendReturn; - } - - // --- MINING --- - emit('mining'); - const miningStart = Date.now(); - - const waitOpts = typeof opts.wait === 'object' ? opts.wait : undefined; - const receipt = await waitForTx(this.aztecNode, txHash, waitOpts); - - const miningDuration = Date.now() - miningStart; - phases.push({ name: 'Mining', duration: miningDuration, color: '#4caf50' }); - - emit('complete'); - return receipt as SendReturn; - } catch (err) { - emit('error', { error: err instanceof Error ? err.message : 'Transaction failed' }); - throw err; - } - } -} diff --git a/src/services/contractService.ts b/src/services/contractService.ts index 5f98ce7..9d50cd7 100644 --- a/src/services/contractService.ts +++ b/src/services/contractService.ts @@ -9,11 +9,9 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; import { AztecAddress as AztecAddressClass } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import { BatchCall, getContractInstanceFromInstantiationParams } from '@aztec/aztec.js/contracts'; -import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; -import { SPONSORED_FPC_SALT } from '@aztec/constants'; import type { TxReceipt } from '@aztec/stdlib/tx'; import type { TokenContract } from '@aztec/noir-contracts.js/Token'; -import type { AMMContract } from '@aztec/noir-contracts.js/AMM'; +import type { AMMContract } from '../../contracts/target/AMM'; import type { ProofOfPasswordContract } from '../../contracts/target/ProofOfPassword'; import { BigDecimal } from '../utils/bigDecimal'; import type { NetworkConfig } from '../config/networks'; @@ -35,17 +33,6 @@ export interface DripContracts { pop: ProofOfPasswordContract; } -/** - * Helper function to get SponsoredFPC contract data - */ -export async function getSponsoredFPCData() { - const { SponsoredFPCContractArtifact } = await import('@aztec/noir-contracts.js/SponsoredFPC'); - const sponsoredFPCInstance = await getContractInstanceFromInstantiationParams(SponsoredFPCContractArtifact, { - salt: new Fr(SPONSORED_FPC_SALT), - }); - return { artifact: SponsoredFPCContractArtifact, instance: sponsoredFPCInstance }; -} - /** * Registers contracts needed for the swap flow * Returns the contract instances after registration @@ -65,7 +52,7 @@ export async function registerSwapContracts( // Import contract artifacts const { TokenContract, TokenContractArtifact } = await import('@aztec/noir-contracts.js/Token'); - const { AMMContract, AMMContractArtifact } = await import('@aztec/noir-contracts.js/AMM'); + const { AMMContract, AMMContractArtifact } = await import('../../contracts/target/AMM'); // Check which contracts are already registered const [ammMetadata, gregoCoinMetadata, gregoCoinPremiumMetadata] = await wallet.batch([ @@ -142,13 +129,19 @@ export async function registerDripContracts( '../../contracts/target/ProofOfPassword' ); - const { instance: sponsoredFPCInstance, artifact: SponsoredFPCContractArtifact } = await getSponsoredFPCData(); + // Determine which FPC to use: subscription FPC (preferred) or fallback to Aztec's sponsored FPC + const subFPC = network.subscriptionFPC; // Check which contracts are already registered - const [popMetadata, sponsoredFPCMetadata] = await wallet.batch([ + const metadataChecks: { name: 'getContractMetadata'; args: [AztecAddress] }[] = [ { name: 'getContractMetadata', args: [popAddress] }, - { name: 'getContractMetadata', args: [sponsoredFPCInstance.address] }, - ]); + ]; + if (subFPC) { + metadataChecks.push({ name: 'getContractMetadata', args: [AztecAddressClass.fromString(subFPC.address)] }); + } + + const metadataResults = await wallet.batch(metadataChecks); + const popMetadata = metadataResults[0]; // Build registration batch for unregistered contracts only const registrationBatch: { name: 'registerContract'; args: [any, any, any] }[] = []; @@ -157,10 +150,23 @@ export async function registerDripContracts( const instance = await node.getContract(popAddress); registrationBatch.push({ name: 'registerContract', args: [instance, ProofOfPasswordContractArtifact, undefined] }); } - if (!sponsoredFPCMetadata.result.instance) { + + // Register subscription FPC if configured and not yet registered + if (!subFPC) { + throw new Error('No subscriptionFPC configured for this network'); + } + const subFPCMetadata = metadataResults[1]; + if (!subFPCMetadata.result.instance) { + const fpcAddress = AztecAddressClass.fromString(subFPC.address); + const secretKey = Fr.fromString(subFPC.secretKey); + const instance = await node.getContract(fpcAddress); + if (!instance) { + throw new Error(`Subscription FPC at ${subFPC.address} not found on-chain`); + } + const { SubscriptionFPCContractArtifact } = await import('@gregojuice/contracts/artifacts/SubscriptionFPC'); registrationBatch.push({ name: 'registerContract', - args: [sponsoredFPCInstance, SponsoredFPCContractArtifact, undefined], + args: [instance, SubscriptionFPCContractArtifact, secretKey], }); } @@ -190,7 +196,9 @@ export async function getExchangeRate( gregoCoinPremium.methods.balance_of_public(amm.address), ]); - const [token0Reserve, token1Reserve] = await batchCall.simulate({ from: fromAddress }); + const results = await batchCall.simulate({ from: fromAddress }); + const token0Reserve = results[0].result; + const token1Reserve = results[1].result; return parseFloat(new BigDecimal(token1Reserve).divide(new BigDecimal(token0Reserve)).toString()); } @@ -209,8 +217,8 @@ export async function fetchBalances( gregoCoinPremium.methods.balance_of_private(address), ]); - const [gcBalance, gcpBalance] = await batchCall.simulate({ from: address }); - return [gcBalance, gcpBalance]; + const results = await batchCall.simulate({ from: address }); + return [results[0].result, results[1].result]; } /** @@ -234,7 +242,8 @@ export async function simulateOnboardingQueries( gregoCoinPremium.methods.balance_of_private(address), ]); - const [token0Reserve, token1Reserve, gcBalance, gcpBalance] = await batchCall.simulate({ from: address }); + const results = await batchCall.simulate({ from: address }); + const [token0Reserve, token1Reserve, gcBalance, gcpBalance] = results.map(r => r.result); const exchangeRate = parseFloat(new BigDecimal(token1Reserve).divide(new BigDecimal(token0Reserve)).toString()); return { @@ -258,7 +267,7 @@ export async function executeSwap( const { gregoCoin, gregoCoinPremium, amm } = contracts; const authwitNonce = Fr.random(); - return amm.methods + const { receipt } = await amm.methods .swap_tokens_for_exact_tokens( gregoCoin.address, gregoCoinPremium.address, @@ -267,6 +276,98 @@ export async function executeSwap( authwitNonce, ) .send({ from: fromAddress }); + return receipt; +} + +// ── Subscription state tracking ───────────────────────────────────── + +const SUBSCRIPTION_KEY = 'gregoswap_subscriptions'; + +function subscriptionKey(fpcAddress: string, configIndex: number, userAddress: string): string { + return `${fpcAddress}:${configIndex}:${userAddress}`; +} + +function hasSubscription(fpcAddress: string, configIndex: number, userAddress: string): boolean { + try { + const subs = JSON.parse(localStorage.getItem(SUBSCRIPTION_KEY) ?? '{}'); + return !!subs[subscriptionKey(fpcAddress, configIndex, userAddress)]; + } catch { + return false; + } +} + +function markSubscribed(fpcAddress: string, configIndex: number, userAddress: string) { + try { + const subs = JSON.parse(localStorage.getItem(SUBSCRIPTION_KEY) ?? '{}'); + subs[subscriptionKey(fpcAddress, configIndex, userAddress)] = true; + localStorage.setItem(SUBSCRIPTION_KEY, JSON.stringify(subs)); + } catch { + /* ignore */ + } +} + +/** + * Executes a sponsored swap through the SubscriptionFPC. + * Uses subscribe on first call, sponsor on subsequent calls. + */ +export async function executeSponsoredSwap( + wallet: Wallet, + network: NetworkConfig, + amm: SwapContracts['amm'], + gregoCoin: SwapContracts['gregoCoin'], + gregoCoinPremium: SwapContracts['gregoCoinPremium'], + userAddress: AztecAddress, + amountOut: number, + amountInMax: number, +): Promise { + const subFPC = network.subscriptionFPC; + if (!subFPC) { + throw new Error('No subscriptionFPC configured for this network'); + } + + const authwitNonce = Fr.random(); + const call = await amm.methods + .swap_tokens_for_exact_tokens_from( + userAddress, + gregoCoin.address, + gregoCoinPremium.address, + BigInt(Math.round(amountOut)), + BigInt(Math.round(amountInMax)), + authwitNonce, + ) + .getFunctionCall(); + + const configIndex = subFPC.functions[amm.address.toString()]?.[call.selector.toString()]; + if (configIndex == null) { + throw new Error( + `No subscription config found for AMM ${amm.address.toString()} selector ${call.selector.toString()}`, + ); + } + + const fpcAddress = AztecAddressClass.fromString(subFPC.address); + const { SubscriptionFPCContract } = await import('@gregojuice/contracts/artifacts/SubscriptionFPC'); + const { SubscriptionFPC } = await import('@gregojuice/contracts/subscription-fpc'); + const rawFPC = SubscriptionFPCContract.at(fpcAddress, wallet); + const fpc = new SubscriptionFPC(rawFPC); + + const subscribed = hasSubscription(subFPC.address, configIndex, userAddress.toString()); + + if (subscribed) { + const { receipt } = await fpc.helpers.sponsor({ + call, + configIndex, + userAddress, + }); + return receipt; + } else { + const { receipt } = await fpc.helpers.subscribe({ + call, + configIndex, + userAddress, + }); + markSubscribed(subFPC.address, configIndex, userAddress.toString()); + return receipt; + } } /** @@ -293,21 +394,42 @@ export function parseSwapError(error: unknown): string { } /** - * Executes a drip (token claim) transaction + * Executes a drip (token claim) transaction. + * Uses subscription FPC when configured, falls back to Aztec's sponsored FPC. */ export async function executeDrip( + wallet: Wallet, + network: NetworkConfig, pop: ProofOfPasswordContract, password: string, recipient: AztecAddress, ): Promise { - const { instance: sponsoredFPCInstance } = await getSponsoredFPCData(); + const subFPC = network.subscriptionFPC; + if (!subFPC) { + throw new Error('No subscriptionFPC configured for this network'); + } - return pop.methods.check_password_and_mint(password, recipient).send({ - from: AztecAddressClass.ZERO, - fee: { - paymentMethod: new SponsoredFeePaymentMethod(sponsoredFPCInstance.address), - }, + const call = await pop.methods.check_password_and_mint(password, recipient).getFunctionCall(); + const configIndex = subFPC.functions[pop.address.toString()]?.[call.selector.toString()]; + if (configIndex == null) { + throw new Error(`No subscription config found for ${pop.address.toString()} selector ${call.selector.toString()}`); + } + + const fpcAddress = AztecAddressClass.fromString(subFPC.address); + const { SubscriptionFPCContract } = await import('@gregojuice/contracts/artifacts/SubscriptionFPC'); + const { SubscriptionFPC } = await import('@gregojuice/contracts/subscription-fpc'); + const rawFPC = SubscriptionFPCContract.at(fpcAddress, wallet); + const fpc = new SubscriptionFPC(rawFPC); + + const accounts = await wallet.getAccounts(); + const userAddress = accounts[0]?.item ?? recipient; + + const { receipt } = await fpc.helpers.subscribe({ + call, + configIndex, + userAddress, }); + return receipt; } /** diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 825189f..53f71a7 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -13,11 +13,9 @@ import { type PendingConnection, type DiscoverySession, } from '@aztec/wallet-sdk/manager'; -import { promiseWithResolvers } from '@aztec/foundation/promise'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; -import { EmbeddedWallet } from '../embedded_wallet'; +import { EmbeddedWallet } from '@gregojuice/embedded-wallet'; import type { NetworkConfig } from '../config/networks'; -import { discoverWebWallets } from '../wallet/iframe/iframe-discovery.ts'; /** * Web wallet URLs to probe during discovery. @@ -36,14 +34,17 @@ export function createNodeClient(nodeUrl: string): AztecNode { /** * Creates an embedded wallet and ensures it has an account. - * Uses the wallet's internal DB for persistence — same address is restored on reload. - * Returns the wallet and the account address. + * Uses initializerless Schnorr accounts — no on-chain deployment needed. + * The wallet's internal DB persists the account, so the same address is restored on reload. */ export async function createEmbeddedWallet( node: AztecNode, ): Promise<{ wallet: EmbeddedWallet; address: AztecAddress }> { const wallet = await EmbeddedWallet.create(node, { pxeConfig: { proverEnabled: true } }); - const accountManager = await wallet.getOrCreateAccount(); + let accountManager = await wallet.loadStoredAccount(); + if (!accountManager) { + accountManager = await wallet.createInitializerlessAccount(); + } return { wallet, address: accountManager.address }; } @@ -62,102 +63,14 @@ export function getChainInfo(network: NetworkConfig): ChainInfo { * Returns a DiscoverySession that yields providers as they are discovered. */ export function discoverWallets(chainInfo: ChainInfo, timeout?: number): DiscoverySession { - // Extension wallets - const extensionSession = WalletManager.configure({ extensions: { enabled: true } }).getAvailableWallets({ + return WalletManager.configure({ + extensions: { enabled: true }, + webWallets: { urls: WEB_WALLET_URLS }, + }).getAvailableWallets({ chainInfo, appId: APP_ID, timeout, }); - - // Web wallets (probed via hidden iframe) - const webSession = discoverWebWallets(WEB_WALLET_URLS, chainInfo); - - // Merge both sessions into one DiscoverySession - return mergeDiscoverySessions([extensionSession, webSession]); -} - -/** - * Merges multiple DiscoverySessions into one. - * Providers from all sessions are emitted as they arrive. - * The merged session completes when all sub-sessions complete. - */ -function mergeDiscoverySessions(sessions: DiscoverySession[]): DiscoverySession { - const { promise: donePromise, resolve: resolveDone } = promiseWithResolvers(); - - let cancelled = false; - const pending: WalletProvider[] = []; - let pendingResolve: ((result: IteratorResult) => void) | null = null; - let remaining = sessions.length; - - function emit(provider: WalletProvider) { - if (pendingResolve) { - const resolve = pendingResolve; - pendingResolve = null; - resolve({ value: provider, done: false }); - } else { - pending.push(provider); - } - } - - function markOneDone() { - remaining--; - if (remaining === 0) { - resolveDone(); - if (pendingResolve) { - const resolve = pendingResolve; - pendingResolve = null; - resolve({ value: undefined as any, done: true }); - } - } - } - - // Drain each session in background - for (const session of sessions) { - (async () => { - try { - for await (const provider of session.wallets) { - if (cancelled) break; - emit(provider); - } - } catch { - // ignore - } finally { - markOneDone(); - } - })(); - } - - const wallets: AsyncIterable = { - [Symbol.asyncIterator]() { - return { - async next(): Promise> { - if (remaining === 0 && pending.length === 0) { - return { value: undefined as any, done: true }; - } - if (pending.length > 0) { - return { value: pending.shift()!, done: false }; - } - return new Promise(resolve => { - pendingResolve = resolve; - }); - }, - async return() { - resolveDone(); - return { value: undefined as any, done: true }; - }, - }; - }, - }; - - return { - wallets, - done: donePromise, - cancel: () => { - cancelled = true; - sessions.forEach(s => s.cancel()); - resolveDone(); - }, - }; } /** @@ -192,12 +105,3 @@ export async function disconnectProvider(provider: WalletProvider): Promise { - if (await wallet.isAccountDeployed()) { - return; - } - await wallet.deployAccount(); -} diff --git a/src/tx-progress.ts b/src/tx-progress.ts deleted file mode 100644 index 36986b7..0000000 --- a/src/tx-progress.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Transaction Progress Tracking - * Event-based system for reporting tx lifecycle phases from the embedded wallet. - * The EmbeddedWallet emits events; the TxNotificationCenter listens and renders toast UI. - * Completed/errored events are persisted to localStorage, scoped by account address. - */ - -export type TxPhase = 'simulating' | 'proving' | 'sending' | 'mining' | 'complete' | 'error'; - -export interface PhaseTiming { - name: string; - duration: number; - color: string; - breakdown?: Array<{ label: string; duration: number }>; - /** Extra detail lines shown in the tooltip (e.g. RPC round-trip info) */ - details?: string[]; -} - -export interface TxProgressEvent { - txId: string; - label: string; - phase: TxPhase; - /** Wall-clock start time (Date.now()) of this tx */ - startTime: number; - /** Wall-clock start time of the current phase (Date.now() at emit time) */ - phaseStartTime: number; - /** Detailed phase breakdown for the timeline bar */ - phases: PhaseTiming[]; - /** Error message if phase === 'error' */ - error?: string; -} - -type TxProgressListener = (event: TxProgressEvent) => void; - -const STORAGE_PREFIX = 'gregoswap_tx_history_'; -const MAX_STORED = 50; - -class TxProgressEmitter { - private listeners = new Set(); - private accountKey: string | null = null; - - subscribe(listener: TxProgressListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - emit(event: TxProgressEvent) { - for (const listener of this.listeners) { - listener(event); - } - // Persist terminal events - if (event.phase === 'complete' || event.phase === 'error') { - this.persist(event); - } - } - - /** Set the active account to scope persistent storage. Loads existing history. */ - setAccount(address: string) { - this.accountKey = `${STORAGE_PREFIX}${address}`; - } - - /** Load persisted history for the current account. */ - loadHistory(): TxProgressEvent[] { - if (!this.accountKey) return []; - try { - const raw = localStorage.getItem(this.accountKey); - if (!raw) return []; - const events = JSON.parse(raw) as TxProgressEvent[]; - // Backfill phaseStartTime for events persisted before this field existed - return events.map(e => ({ phaseStartTime: e.startTime, ...e })); - } catch { - return []; - } - } - - /** Remove a tx from persisted storage. */ - dismissPersisted(txId: string) { - if (!this.accountKey) return; - try { - const history = this.loadHistory().filter(e => e.txId !== txId); - localStorage.setItem(this.accountKey, JSON.stringify(history)); - } catch { - // localStorage unavailable - } - } - - private persist(event: TxProgressEvent) { - if (!this.accountKey) return; - try { - const history = this.loadHistory(); - const idx = history.findIndex(e => e.txId === event.txId); - if (idx >= 0) { - history[idx] = event; - } else { - history.push(event); - } - // Keep only the most recent entries - const trimmed = history.slice(-MAX_STORED); - localStorage.setItem(this.accountKey, JSON.stringify(trimmed)); - } catch { - // localStorage unavailable - } - } -} - -/** Singleton emitter shared between EmbeddedWallet and UI */ -export const txProgress = new TxProgressEmitter(); diff --git a/src/wallet/iframe/iframe-discovery.ts b/src/wallet/iframe/iframe-discovery.ts deleted file mode 100644 index 1713876..0000000 --- a/src/wallet/iframe/iframe-discovery.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Web wallet discovery — creates IframeWalletProvider instances from a list of URLs. - * - * For each configured URL we probe the wallet by loading a tiny invisible iframe, - * waiting for WALLET_READY, then sending a DISCOVERY. On a successful - * DISCOVERY_RESPONSE we emit an IframeWalletProvider to the caller. - * - * This is intentionally lightweight (no key exchange yet) — key exchange happens - * later when the user selects the wallet and calls `provider.establishSecureChannel()`. - */ - -import type { ChainInfo } from '@aztec/aztec.js/account'; -import type { DiscoverySession, WalletProvider } from '@aztec/wallet-sdk/manager'; -import { promiseWithResolvers } from '@aztec/foundation/promise'; -import { IframeMessageType } from './iframe-message-types.ts'; -import { IframeWalletProvider } from './iframe-provider.ts'; - -const PROBE_TIMEOUT_MS = 10_000; - -/** - * Probes a list of web wallet URLs and returns a DiscoverySession compatible - * with WalletManager's getAvailableWallets() interface. - * - * Discovered IframeWalletProvider instances are yielded asynchronously as each - * wallet responds to the probe. - */ -export function discoverWebWallets( - walletUrls: string[], - chainInfo: ChainInfo, -): DiscoverySession { - const { promise: donePromise, resolve: resolveDone } = promiseWithResolvers(); - - let cancelled = false; - const pendingProviders: WalletProvider[] = []; - let pendingResolve: ((result: IteratorResult) => void) | null = null; - let completed = false; - - function emit(provider: WalletProvider) { - if (pendingResolve) { - const resolve = pendingResolve; - pendingResolve = null; - resolve({ value: provider, done: false }); - } else { - pendingProviders.push(provider); - } - } - - function markComplete() { - completed = true; - resolveDone(); - if (pendingResolve) { - const resolve = pendingResolve; - pendingResolve = null; - resolve({ value: undefined as any, done: true }); - } - } - - // Probe all URLs in parallel - const probes = walletUrls.map((url) => probeWallet(url, chainInfo, PROBE_TIMEOUT_MS).then( - (provider) => { if (!cancelled && provider) emit(provider); }, - () => {}, // ignore probe errors - )); - - Promise.all(probes).then(() => { - if (!cancelled) markComplete(); - }); - - const wallets: AsyncIterable = { - [Symbol.asyncIterator]() { - return { - async next(): Promise> { - if (completed && pendingProviders.length === 0) { - return { value: undefined as any, done: true }; - } - if (pendingProviders.length > 0) { - return { value: pendingProviders.shift()!, done: false }; - } - return new Promise((resolve) => { - pendingResolve = resolve; - }); - }, - async return() { - markComplete(); - return { value: undefined as any, done: true }; - }, - }; - }, - }; - - return { - wallets, - done: donePromise, - cancel: () => { - cancelled = true; - markComplete(); - }, - }; -} - -/** - * Probes a single web wallet URL. - * Creates a temporary hidden iframe, waits for WALLET_READY, sends DISCOVERY_REQUEST. - * Returns an IframeWalletProvider on success, null on timeout/failure. - */ -async function probeWallet( - walletUrl: string, - chainInfo: ChainInfo, - timeoutMs: number, -): Promise { - const walletOrigin = new URL(walletUrl).origin; - const iframe = document.createElement('iframe'); - iframe.src = walletUrl; - iframe.style.display = 'none'; - iframe.style.width = '0'; - iframe.style.height = '0'; - iframe.style.border = 'none'; - iframe.style.position = 'absolute'; - iframe.style.top = '-9999px'; - iframe.allow = 'storage-access; cross-origin-isolated'; - document.body.appendChild(iframe); - - return new Promise((resolve) => { - let timer: ReturnType; - - const cleanup = () => { - if (iframe.parentNode) iframe.parentNode.removeChild(iframe); - window.removeEventListener('message', handler); - clearTimeout(timer); - }; - - timer = setTimeout(() => { - cleanup(); - resolve(null); - }, timeoutMs); - - let step: 'waiting-ready' | 'waiting-discovery' = 'waiting-ready'; - const requestId = globalThis.crypto.randomUUID(); - - function handler(event: MessageEvent) { - if (event.origin !== walletOrigin) return; - const msg = event.data; - if (!msg || typeof msg !== 'object') return; - - if (step === 'waiting-ready' && msg.type === IframeMessageType.WALLET_READY) { - step = 'waiting-discovery'; - iframe.contentWindow?.postMessage( - { type: IframeMessageType.DISCOVERY, requestId, appId: 'gregoswap-discovery' }, - walletOrigin, - ); - } else if ( - step === 'waiting-discovery' && - msg.type === IframeMessageType.DISCOVERY_RESPONSE && - msg.requestId === requestId - ) { - const info = msg.walletInfo as { id: string; name: string; version: string; icon?: string }; - cleanup(); - resolve( - new IframeWalletProvider(info.id, info.name, info.icon, walletUrl, chainInfo), - ); - } - } - - window.addEventListener('message', handler); - }); -} diff --git a/src/wallet/iframe/iframe-message-types.ts b/src/wallet/iframe/iframe-message-types.ts deleted file mode 100644 index 646a6cd..0000000 --- a/src/wallet/iframe/iframe-message-types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Extended message types for iframe wallet communication. - * - * Re-exports WalletMessageType from the SDK and adds iframe-specific types - * needed for postMessage transport (where MessagePort is unavailable). - * - * TODO: Upstream these to @aztec/wallet-sdk/types when iframe wallet support - * is fully integrated into the SDK. - */ -import { WalletMessageType } from '@aztec/wallet-sdk/types'; - -export const IframeMessageType = { - ...WalletMessageType, - /** Wallet iframe ready signal (iframe announces it has loaded) */ - WALLET_READY: 'aztec-wallet-ready', - /** Encrypted wallet message wrapper (for postMessage transport) */ - SECURE_MESSAGE: 'aztec-wallet-secure-message', - /** Encrypted wallet response wrapper (for postMessage transport) */ - SECURE_RESPONSE: 'aztec-wallet-secure-response', - /** Session disconnected notification */ - SESSION_DISCONNECTED: 'aztec-wallet-session-disconnected', -} as const; diff --git a/src/wallet/iframe/iframe-provider.ts b/src/wallet/iframe/iframe-provider.ts deleted file mode 100644 index 477f971..0000000 --- a/src/wallet/iframe/iframe-provider.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * IframeWalletProvider — implements WalletProvider for web wallets loaded in iframes. - * - * Flow (mirrors ExtensionProvider from @aztec/wallet-sdk): - * 1. Creates an