diff --git a/README.md b/README.md index 47149b1..93c4b59 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,41 @@ This will: - Deploy the ProofOfPassword contract - Generate a `deployed-addresses.json` file with contract addresses -#### 4. Start the Development Server +#### 4. Deploy the Subscription FPC + +GregoSwap uses a [SubscriptionFPC](https://github.com/Thunkar/gregojuice) (Fee Payment +Contract) to sponsor user transactions: the drip, the swap, and the offchain send all +run for free from the user's perspective, with the FPC paying gas in Fee Juice. + +If you are testing on a local network, you will need to bootstrap FPC infrastructure. + +After the base contracts are in place, you can deploy and configure the FPC with: + +```bash +yarn deploy:fpc:local +``` + +This single command does everything needed to bring the FPC online against the local +sandbox: + +- Deploys a fresh `SubscriptionFPC` with generated keys +- Bridges fee juice from L1 (Anvil) to the FPC's L2 address so it can actually pay gas +- Calls `sign_up` on the FPC for each sponsored function declared in + `scripts/deploy-subscription-fpc.ts` (currently: `PoP.check_password_and_mint`, + `AMM.swap_tokens_for_exact_tokens_from`, and + `Token.transfer_in_private_deliver_offchain` on both token contracts) +- Claims the L1→L2 message on behalf of the FPC so its balance is usable +- Writes the FPC address, secret key, and function-selector map into + `src/config/networks/local.json` under `subscriptionFPC` + +The script is idempotent over the underlying config: re-running it deploys a new FPC +and overwrites the `subscriptionFPC` block. You can use a different config file via +`NETWORK_CONFIG_PATH`. + +Note this is not needed to test on Testnet's or Mainnet's, since there the SubscriptionFPC infrastructure is already set up. + +#### 5. Start the Development Server ```bash -yarn serve +yarn dev ``` diff --git a/contracts/Nargo.toml b/contracts/Nargo.toml index dcda87a..c429506 100644 --- a/contracts/Nargo.toml +++ b/contracts/Nargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ "proof_of_password", - "amm" + "amm", + "token" ] \ No newline at end of file diff --git a/contracts/proof_of_password/Nargo.toml b/contracts/proof_of_password/Nargo.toml index a4f85a1..e26b6f7 100644 --- a/contracts/proof_of_password/Nargo.toml +++ b/contracts/proof_of_password/Nargo.toml @@ -7,4 +7,4 @@ authors = [""] aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.3.0-nightly.20260417", directory = "noir-projects/aztec-nr/aztec" } token = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.3.0-nightly.20260417", directory = "noir-projects/noir-contracts/contracts/app/token_contract" } poseidon = { tag = "v0.3.0", git = "https://github.com/noir-lang/poseidon" } -compressed_string = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.3.0-nightly.20260417", directory = "noir-projects/aztec-nr/compressed-string" } \ No newline at end of file +compressed_string = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.3.0-nightly.20260417", directory = "noir-projects/aztec-nr/compressed-string" } diff --git a/contracts/token/Nargo.toml b/contracts/token/Nargo.toml new file mode 100644 index 0000000..60245f1 --- /dev/null +++ b/contracts/token/Nargo.toml @@ -0,0 +1,10 @@ +[package] +name = "token_contract" +authors = [""] +type = "contract" + +[dependencies] +aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.3.0-nightly.20260417", directory = "noir-projects/aztec-nr/aztec" } +uint_note = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.3.0-nightly.20260417", directory = "noir-projects/aztec-nr/uint-note" } +compressed_string = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.3.0-nightly.20260417", directory = "noir-projects/aztec-nr/compressed-string" } +balance_set = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.3.0-nightly.20260417", directory = "noir-projects/aztec-nr/balance-set" } diff --git a/contracts/token/src/main.nr b/contracts/token/src/main.nr new file mode 100644 index 0000000..c3760f6 --- /dev/null +++ b/contracts/token/src/main.nr @@ -0,0 +1,548 @@ +// docs:start:imports +use aztec::macros::aztec; + +// Minimal token implementation that supports `AuthWit` accounts. +// The auth message follows a similar pattern to the cross-chain message and includes a designated caller. +// The designated caller is ALWAYS used here, and not based on a flag as cross-chain. +// message hash = H([caller, contract, selector, ...args]) +// To be read as `caller` calls function at `contract` defined by `selector` with `args` +// Including a nonce in the message hash ensures that the message can only be used once. +#[aztec] +pub contract Token { + // Libs + use std::ops::{Add, Sub}; + + use compressed_string::FieldCompressedString; + + use aztec::{ + authwit::auth::compute_authwit_nullifier, + context::{PrivateCall, PrivateContext}, + macros::{ + events::event, + functions::{authorize_once, external, initializer, internal, only_self, view}, + storage::storage, + }, + messages::message_delivery::MessageDelivery, + protocol::{address::AztecAddress, traits::ToField}, + state_vars::{Map, Owned, PublicImmutable, PublicMutable, StateVariable}, + }; + + use uint_note::{PartialUintNote, UintNote}; + + use balance_set::BalanceSet; + + // docs:end::imports + + // In the first transfer iteration we are computing a lot of additional information (validating inputs, retrieving + // keys, etc.), so the gate count is already relatively high. We therefore only read a few notes to keep the happy + // case with few constraints. + global INITIAL_TRANSFER_CALL_MAX_NOTES: u32 = 2; + // All the recursive call does is nullify notes, meaning the gate count is low, but it is all constant overhead. We + // therefore read more notes than in the base case to increase the efficiency of the overhead, since this results in + // an overall small circuit regardless. + global RECURSIVE_TRANSFER_CALL_MAX_NOTES: u32 = 8; + + #[event] + struct Transfer { + from: AztecAddress, + to: AztecAddress, + amount: u128, + } + + // docs:start:storage_struct + #[storage] + struct Storage { + admin: PublicMutable, + minters: Map, Context>, + balances: Owned, Context>, + total_supply: PublicMutable, + public_balances: Map, Context>, + symbol: PublicImmutable, + name: PublicImmutable, + decimals: PublicImmutable, + } + // docs:end:storage_struct + + // docs:start:constructor + #[external("public")] + #[initializer] + fn constructor(admin: AztecAddress, name: str<31>, symbol: str<31>, decimals: u8) { + assert(!admin.is_zero(), "invalid admin"); + self.storage.admin.write(admin); + self.storage.minters.at(admin).write(true); + self.storage.name.initialize(FieldCompressedString::from_string(name)); + self.storage.symbol.initialize(FieldCompressedString::from_string(symbol)); + self.storage.decimals.initialize(decimals); + } + // docs:end:constructor + + #[external("public")] + fn set_admin(new_admin: AztecAddress) { + assert(self.storage.admin.read().eq(self.msg_sender()), "caller is not admin"); + self.storage.admin.write(new_admin); + } + + // docs:start:public_immutable_read + #[external("public")] + #[view] + fn public_get_name() -> FieldCompressedString { + self.storage.name.read() + } + + #[external("private")] + #[view] + fn private_get_name() -> FieldCompressedString { + self.storage.name.read() + } + // docs:end:public_immutable_read + + #[external("public")] + #[view] + fn public_get_symbol() -> pub FieldCompressedString { + self.storage.symbol.read() + } + + #[external("private")] + #[view] + fn private_get_symbol() -> pub FieldCompressedString { + self.storage.symbol.read() + } + + #[external("public")] + #[view] + fn public_get_decimals() -> pub u8 { + self.storage.decimals.read() + } + + #[external("private")] + #[view] + fn private_get_decimals() -> pub u8 { + self.storage.decimals.read() + } + + #[external("public")] + #[view] + fn get_admin() -> Field { + self.storage.admin.read().to_field() + } + + #[external("public")] + #[view] + fn is_minter(minter: AztecAddress) -> bool { + self.storage.minters.at(minter).read() + } + + #[external("public")] + #[view] + fn total_supply() -> u128 { + self.storage.total_supply.read() + } + + #[external("public")] + #[view] + fn balance_of_public(owner: AztecAddress) -> u128 { + self.storage.public_balances.at(owner).read() + } + + // docs:start:set_minter + #[external("public")] + fn set_minter(minter: AztecAddress, approve: bool) { + assert(self.storage.admin.read().eq(self.msg_sender()), "caller is not admin"); + self.storage.minters.at(minter).write(approve); + } + // docs:end:set_minter + + #[external("public")] + fn mint_to_public(to: AztecAddress, amount: u128) { + assert(self.storage.minters.at(self.msg_sender()).read(), "caller is not minter"); + let new_balance = self.storage.public_balances.at(to).read().add(amount); + let supply = self.storage.total_supply.read().add(amount); + self.storage.public_balances.at(to).write(new_balance); + self.storage.total_supply.write(supply); + } + + // docs:start:transfer_in_public + #[authorize_once("from", "authwit_nonce")] + #[external("public")] + fn transfer_in_public( + from: AztecAddress, + to: AztecAddress, + amount: u128, + authwit_nonce: Field, + ) { + let from_balance = self.storage.public_balances.at(from).read().sub(amount); + self.storage.public_balances.at(from).write(from_balance); + let to_balance = self.storage.public_balances.at(to).read().add(amount); + self.storage.public_balances.at(to).write(to_balance); + } + // docs:end:transfer_in_public + + #[authorize_once("from", "authwit_nonce")] + #[external("public")] + fn burn_public(from: AztecAddress, amount: u128, authwit_nonce: Field) { + let from_balance = self.storage.public_balances.at(from).read().sub(amount); + self.storage.public_balances.at(from).write(from_balance); + let new_supply = self.storage.total_supply.read().sub(amount); + self.storage.total_supply.write(new_supply); + } + + #[authorize_once("from", "authwit_nonce")] + #[external("private")] + fn transfer_to_public( + from: AztecAddress, + to: AztecAddress, + amount: u128, + authwit_nonce: Field, + ) { + self.storage.balances.at(from).sub(amount).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + self.enqueue_self._increase_public_balance(to, amount); + } + + /// Transfers tokens from private balance of `from` to public balance of `to` and prepares a partial note for + /// receiving change for `from`. + /// + /// This is an optimization that combines two operations into one to reduce contract calls: + /// 1. Transfers `amount` tokens from `from`'s private balance to `to`'s public balance + /// 2. Creates a partial note that can later be used to receive change back to `from`'s private balance + /// + /// This pattern is useful when interacting with contracts that: + /// - Receive tokens from a user's private balance + /// - Need to wait until public execution to determine how many tokens to return (e.g. AMM, FPC) + /// - Will return tokens to the user's private balance + /// + /// The contract can use the returned partial note to complete the transfer back to private + /// once the final amount is known during public execution. + #[authorize_once("from", "authwit_nonce")] + #[external("private")] + fn transfer_to_public_and_prepare_private_balance_increase( + from: AztecAddress, + to: AztecAddress, + amount: u128, + authwit_nonce: Field, + ) -> PartialUintNote { + self.storage.balances.at(from).sub(amount).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + self.enqueue_self._increase_public_balance(to, amount); + + // We prepare the private balance increase (the partial note for the change). + self.internal._prepare_private_balance_increase(from) + } + + #[external("private")] + fn transfer(to: AztecAddress, amount: u128) { + let from = self.msg_sender(); + + // We reduce `from`'s balance by amount by recursively removing notes over potentially multiple calls. This + // method keeps the gate count for each individual call low - reading too many notes at once could result in + // circuits in which proving is not feasible. + // Since the sum of the amounts in the notes we nullified was potentially larger than amount, we create a new + // note for `from` with the change amount, e.g. if `amount` is 10 and two notes are nullified with amounts 8 and + // 5, then the change will be 3 (since 8 + 5 - 10 = 3). + let change = self.internal.subtract_balance(from, amount, INITIAL_TRANSFER_CALL_MAX_NOTES); + self.storage.balances.at(from).add(change).deliver(MessageDelivery.ONCHAIN_UNCONSTRAINED); + self.storage.balances.at(to).add(amount).deliver(MessageDelivery.ONCHAIN_UNCONSTRAINED); + + // We don't constrain encryption of the note log in `transfer` (unlike in `transfer_in_private`) because the transfer + // function is only designed to be used in situations where the event is not strictly necessary (e.g. payment to + // another person where the payment is considered to be successful when the other party successfully decrypts a + // note). + self.emit(Transfer { from, to, amount }).deliver_to( + to, + MessageDelivery.ONCHAIN_UNCONSTRAINED, + ); + } + + #[authorize_once("from", "authwit_nonce")] + #[external("private")] + fn transfer_in_private_deliver_offchain( + from: AztecAddress, + to: AztecAddress, + amount: u128, + authwit_nonce: Field, + ) { + self.storage.balances.at(from).sub(amount).deliver(MessageDelivery.OFFCHAIN); + self.storage.balances.at(to).add(amount).deliver(MessageDelivery.OFFCHAIN); + + self.emit(Transfer { from, to, amount }).deliver_to( + to, + MessageDelivery.OFFCHAIN, + ); + } + + #[internal("private")] + fn subtract_balance(account: AztecAddress, amount: u128, max_notes: u32) -> u128 { + let subtracted = self.storage.balances.at(account).try_sub(amount, max_notes); + // Failing to subtract any amount means that the owner was unable to produce more notes that could be nullified. + // We could in some cases fail early inside try_sub if we detected that fewer notes than the maximum were + // returned and we were still unable to reach the target amount, but that'd make the code more complicated, and + // optimizing for the failure scenario is not as important. + assert(subtracted > 0 as u128, "Balance too low"); + if subtracted >= amount { + // We have achieved our goal of nullifying notes that add up to more than amount, so we return the change + subtracted - amount + } else { + // try_sub failed to nullify enough notes to reach the target amount, so we compute the amount remaining + // and try again. + let remaining = amount - subtracted; + compute_recurse_subtract_balance_call(*context, account, remaining).call(context) + } + } + + // TODO(#7729): apply no_predicates to the contract interface method directly instead of having to use a wrapper + // like we do here. + #[no_predicates] + #[contract_library_method] + fn compute_recurse_subtract_balance_call( + context: PrivateContext, + account: AztecAddress, + remaining: u128, + ) -> PrivateCall<25, 2, u128> { + Token::at(context.this_address())._recurse_subtract_balance(account, remaining) + } + + #[only_self] + #[external("private")] + fn _recurse_subtract_balance(account: AztecAddress, amount: u128) -> u128 { + self.internal.subtract_balance(account, amount, RECURSIVE_TRANSFER_CALL_MAX_NOTES) + } + + /** + * Cancel a private authentication witness. + * @param inner_hash The inner hash of the authwit to cancel. + */ + // docs:start:cancel_authwit + #[external("private")] + fn cancel_authwit(inner_hash: Field) { + let on_behalf_of = self.msg_sender(); + let nullifier = compute_authwit_nullifier(on_behalf_of, inner_hash); + self.context.push_nullifier(nullifier); + } + // docs:end:cancel_authwit + + // docs:start:transfer_in_private + #[authorize_once("from", "authwit_nonce")] + #[external("private")] + fn transfer_in_private( + from: AztecAddress, + to: AztecAddress, + amount: u128, + authwit_nonce: Field, + ) { + // docs:start:increase_private_balance + self.storage.balances.at(from).sub(amount).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + // docs:end:increase_private_balance + self.storage.balances.at(to).add(amount).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + } + // docs:end:transfer_in_private + + #[authorize_once("from", "authwit_nonce")] + #[external("private")] + fn burn_private(from: AztecAddress, amount: u128, authwit_nonce: Field) { + self.storage.balances.at(from).sub(amount).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + self.enqueue_self._reduce_total_supply(amount); + } + + // Transfers token `amount` from public balance of message sender to a private balance of `to`. + #[external("private")] + fn transfer_to_private(to: AztecAddress, amount: u128) { + // `from` is the owner of the public balance from which we'll subtract the `amount`. + let from = self.msg_sender(); + + // We prepare the private balance increase (the partial note). + let partial_note = self.internal._prepare_private_balance_increase(to); + + // At last we finalize the transfer. Usage of the `unsafe` method here is safe because we set the `from` + // function argument to a message sender, guaranteeing that he can transfer only his own tokens. + self.enqueue_self._finalize_transfer_to_private_unsafe(from, amount, partial_note); + } + + /// Prepares an increase of private balance of `to` (partial note). The increase needs to be finalized by calling + /// some of the finalization functions (`finalize_transfer_to_private`, `finalize_mint_to_private`) with the + /// returned partial note. + #[external("private")] + fn prepare_private_balance_increase(to: AztecAddress) -> PartialUintNote { + self.internal._prepare_private_balance_increase(to) + } + + /// This function exists separately from `prepare_private_balance_increase` solely as an optimization as it allows + /// us to have it inlined in the `transfer_to_private` function which results in one fewer kernel iteration. Note + /// that in this case we don't pass `completer` as an argument to this function because in all the callsites we + /// want to use the message sender as the completer anyway. + // docs:start:prepare_private_balance_increase + #[internal("private")] + fn _prepare_private_balance_increase(to: AztecAddress) -> PartialUintNote { + let partial_note = UintNote::partial(to, self.context, to, self.msg_sender()); + + partial_note + } + // docs:end:prepare_private_balance_increase + + /// Finalizes a transfer of token `amount` from public balance of `msg_sender` to a private balance of `to`. + /// The transfer must be prepared by calling `prepare_private_balance_increase` from `msg_sender` account and + /// the resulting `partial_note` must be passed as an argument to this function. + /// + /// Note that this contract does not protect against a `partial_note` being used multiple times and it is up to + /// the caller of this function to ensure that it doesn't happen. If the same `partial_note` is used multiple + /// times, the token `amount` would most likely get lost (the partial note log processing functionality would fail + /// to find the pending partial note when trying to complete it). + #[external("public")] + fn finalize_transfer_to_private(amount: u128, partial_note: PartialUintNote) { + // Completer is the entity that can complete the partial note. In this case, it's the same as the account + // `from` from whose balance we're subtracting the `amount`. + let from_and_completer = self.msg_sender(); + self.internal._finalize_transfer_to_private(from_and_completer, amount, partial_note); + } + + /// Finalizes a transfer of token `amount` from private balance of `from` to a private balance of `to`. + /// The transfer must be prepared by calling `prepare_private_balance_increase` from `from` account and + /// the resulting `partial_note` must be passed as an argument to this function. + /// + /// Note that this contract does not protect against a `partial_note` being used multiple times and it is up to + /// the caller of this function to ensure that it doesn't happen. If the same `partial_note` is used multiple + /// times, the token `amount` would most likely get lost (the partial note log processing functionality would fail + /// to find the pending partial note when trying to complete it). + #[authorize_once("from", "authwit_nonce")] + #[external("private")] + fn finalize_transfer_to_private_from_private( + from: AztecAddress, + partial_note: PartialUintNote, + amount: u128, + authwit_nonce: Field, + ) { + // First we subtract the `amount` from the private balance of `from` + self.storage.balances.at(from).sub(amount).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + + partial_note.complete_from_private( + self.context, + self.msg_sender(), + self.storage.balances.get_storage_slot(), + amount, + ); + } + + /// This is a wrapper around `_finalize_transfer_to_private` placed here so that a call + /// to `_finalize_transfer_to_private` can be enqueued. Called unsafe as it does not check `from_and_completer` + /// (this has to be done in the calling function). + #[external("public")] + #[only_self] + fn _finalize_transfer_to_private_unsafe( + from_and_completer: AztecAddress, + amount: u128, + partial_note: PartialUintNote, + ) { + self.internal._finalize_transfer_to_private(from_and_completer, amount, partial_note); + } + + // In all the flows in this contract, `from` (the account from which we're subtracting the `amount`) and + // `completer` (the entity that can complete the partial note) are the same so we represent them with a single + // argument. + // docs:start:finalize_transfer_to_private + #[internal("public")] + fn _finalize_transfer_to_private( + from_and_completer: AztecAddress, + amount: u128, + partial_note: PartialUintNote, + ) { + // First we subtract the `amount` from the public balance of `from_and_completer` + let balance_storage = self.storage.public_balances.at(from_and_completer); + + let from_balance = balance_storage.read().sub(amount); + balance_storage.write(from_balance); + + // We finalize the transfer by completing the partial note. + partial_note.complete( + self.context, + from_and_completer, + self.storage.balances.get_storage_slot(), + amount, + ); + } + // docs:end:finalize_transfer_to_private + + /// Mints token `amount` to a private balance of `to`. Message sender has to have minter permissions (checked + /// in the enqueued call). + #[external("private")] + fn mint_to_private(to: AztecAddress, amount: u128) { + // We prepare the partial note to which we'll "send" the minted amount. + let partial_note = self.internal._prepare_private_balance_increase(to); + + // At last we finalize the mint. Usage of the `unsafe` method here is safe because we set + // the `minter_and_completer` function argument to a message sender, guaranteeing that only a message sender + // with minter permissions can successfully execute the function. + self.enqueue_self._finalize_mint_to_private_unsafe(self.msg_sender(), amount, partial_note); + } + + /// Finalizes a mint of token `amount` to a private balance of `to`. The mint must be prepared by calling + /// `prepare_private_balance_increase` first and the resulting + /// `partial_note` must be passed as an argument to this function. + /// + /// Note: This function is only an optimization as it could be replaced by a combination of `mint_to_public` + /// and `finalize_transfer_to_private`. It is however used very commonly so it makes sense to optimize it + /// (e.g. used during token bridging, in AMM liquidity token etc.). + #[external("public")] + fn finalize_mint_to_private(amount: u128, partial_note: PartialUintNote) { + // Completer is the entity that can complete the partial note. In this case, it's the same as the minter + // account. + let minter_and_completer = self.msg_sender(); + assert(self.storage.minters.at(minter_and_completer).read(), "caller is not minter"); + + self.internal._finalize_mint_to_private(minter_and_completer, amount, partial_note); + } + + /// This is a wrapper around `_finalize_mint_to_private` placed here so that a call + /// to `_finalize_mint_to_private` can be enqueued. Called unsafe as it does not check `minter_and_completer` (this + /// has to be done in the calling function). + #[external("public")] + #[only_self] + fn _finalize_mint_to_private_unsafe( + minter_and_completer: AztecAddress, + amount: u128, + partial_note: PartialUintNote, + ) { + // We check the minter permissions as it was not done in `mint_to_private` function. + assert(self.storage.minters.at(minter_and_completer).read(), "caller is not minter"); + self.internal._finalize_mint_to_private(minter_and_completer, amount, partial_note); + } + + #[internal("public")] + fn _finalize_mint_to_private( + completer: AztecAddress, // entity that can complete the partial note + amount: u128, + partial_note: PartialUintNote, + ) { + // First we increase the total supply by the `amount` + let supply = self.storage.total_supply.read().add(amount); + self.storage.total_supply.write(supply); + + // We finalize the transfer by completing the partial note. + partial_note.complete( + self.context, + completer, + self.storage.balances.get_storage_slot(), + amount, + ); + } + + #[external("public")] + #[only_self] + fn _increase_public_balance(to: AztecAddress, amount: u128) { + let to_balance = self.storage.public_balances.at(to); + + let new_balance = to_balance.read().add(amount); + to_balance.write(new_balance); + } + + #[external("public")] + #[only_self] + fn _reduce_total_supply(amount: u128) { + // Only to be called from burn. + let new_supply = self.storage.total_supply.read().sub(amount); + self.storage.total_supply.write(new_supply); + } + + // docs:start:balance_of_private + #[external("utility")] + unconstrained fn balance_of_private(owner: AztecAddress) -> u128 { + self.storage.balances.at(owner).balance_of() + } + // docs:end:balance_of_private +} diff --git a/package.json b/package.json index 1757713..db92d4c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "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", + "deploy:fpc:local": "node --experimental-transform-types scripts/deploy-subscription-fpc.ts", "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", @@ -45,6 +46,7 @@ "@mui/material": "^6.3.1", "@mui/styles": "^6.3.1", "buffer-json": "^2.0.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", diff --git a/scripts/deploy-subscription-fpc.ts b/scripts/deploy-subscription-fpc.ts new file mode 100644 index 0000000..6035f84 --- /dev/null +++ b/scripts/deploy-subscription-fpc.ts @@ -0,0 +1,285 @@ +/** + * Deploys the SubscriptionFPC contract to the local sandbox and updates local.json config. + * + * Usage: node --experimental-transform-types scripts/deploy-subscription-fpc.ts + */ +import fs from 'fs'; +import path from 'path'; +import { SubscriptionFPC } from '@gregojuice/contracts/subscription-fpc'; +import { SubscriptionFPCContract, SubscriptionFPCContractArtifact } from '@gregojuice/contracts/artifacts/SubscriptionFPC'; +import { FunctionSelector } from '@aztec/stdlib/abi'; +import type { ContractArtifact } from '@aztec/aztec.js/abi'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; +import { EmbeddedWallet } from '@aztec/wallets/embedded'; +import { L1FeeJuicePortalManager } from '@aztec/aztec.js/ethereum'; +import { waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; +import { createExtendedL1Client } from '@aztec/ethereum/client'; +import { createLogger } from '@aztec/foundation/log'; +import { foundry } from 'viem/chains'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { FeeJuiceContract } from '@aztec/aztec.js/protocol'; +import { ProofOfPasswordContractArtifact } from '../contracts/target/ProofOfPassword.ts'; +import { AMMContractArtifact } from '../contracts/target/AMM.ts'; +import { TokenContractArtifact } from '../contracts/target/Token.ts'; +import { setupWallet, getOrCreateDeployer } from './utils.ts'; +import type { AztecNode } from '@aztec/aztec.js/node'; + +interface FpcSignupSpec { + artifact: ContractArtifact; + functionName: string; + contractAlias: string[]; + /** Max sponsored calls per subscribed user. Falls back to DEFAULTS.fpcSignupDefaults.maxUses. */ + maxUses?: number; + /** Max fee (in FJ wei) the FPC will cover per sponsored call. Falls back to DEFAULTS.fpcSignupDefaults.maxFee. */ + maxFee?: bigint; + /** Max concurrent subscribers for this slot. Falls back to DEFAULTS.fpcSignupDefaults.maxUsers. */ + maxUsers?: number; +} + +const DEFAULTS = { + // Path to the network config file to load/update. + // Overridable via NETWORK_CONFIG_PATH env var. + configPath: path.join(import.meta.dirname, '../src/config/networks/local.json'), + + // L1 RPC URL used for fee juice bridging. + // Defaults to local Anvil. Persisted to the network config on first run. + l1RpcUrl: 'http://localhost:8545', + + // Private key used to sign L1 transactions during FPC setup (fee juice bridging, etc.). + // Defaults to Anvil's pre-funded account #0 for local sandbox development. + // Persisted to the network config on first run so it can be overridden per-network. + l1FunderKey: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + + // Default sign-up parameters applied to each FpcSignupSpec that doesn't override them. + fpcSignupDefaults: { + maxUses: 100, + maxFee: BigInt('1000000000000000000000'), // 1000 FJ + maxUsers: 100, + }, + + // Contract functions to sign up on the SubscriptionFPC. + fpcSignups: [ + { + artifact: ProofOfPasswordContractArtifact, + functionName: 'check_password_and_mint', + contractAlias: ['pop'], + }, + { + artifact: AMMContractArtifact, + functionName: 'swap_tokens_for_exact_tokens_from', + contractAlias: ['amm'], + }, + { + artifact: TokenContractArtifact, + functionName: 'transfer_in_private_deliver_offchain', + contractAlias: ['gregoCoin', 'gregoCoinPremium'], + }, + ] as FpcSignupSpec[], +}; + +async function main() { + const configPath = process.env.NETWORK_CONFIG_PATH ?? DEFAULTS.configPath; + const config = loadConfig(configPath); + + const { wallet, node, paymentMethod } = await setupWallet(config.nodeUrl, 'local'); + const fpcDeployer = await getOrCreateDeployer(wallet, paymentMethod); + + const { fpcAddress, secretKey } = await deployAndRegisterSubscriptionFpc(node, wallet, fpcDeployer, paymentMethod); + + // Order matters here: + // + // 1. bridgeTokens() submits the L1 mint + bridge tx. This is only the L1 half of the flow: the L1->L2 message is now + // pending and needs the L2 sequencer to pick it up before we can claim. On local setups L2 sequencer is quiet + // when nothing else is happening, so we can't just wait. + // + // 2. executeFpcSignUps() fires a burst of L2 txs (one per sponsored function). Beyond their functional purpose, + // these txs force L2 block production, which advances the chain past the checkpoint containing our pending bridge + // message. + // + // 3. claimFeeJuiceOnL2() claims tx crediting the FPC's public fee juice balance so it can actually sponsor user + // calls. + // + // If we did them in the "obvious" order (bridge -> claim -> sign_up), the claim would hang forever waiting for an L2 + // block that never comes... so it is a bit of a hack, but it works. + const feeJuiceClaim = await bridgeTokens(node, config.l1RpcUrl, config.l1FunderKey, fpcAddress); + const signedUpFunctions = await executeFpcSignUps(fpcAddress, fpcDeployer, wallet, paymentMethod, config.contracts); + await claimFeeJuiceOnL2(node, feeJuiceClaim, wallet, fpcAddress, fpcDeployer, paymentMethod); + updateNetworkConfigFile(config, fpcAddress, secretKey, signedUpFunctions, configPath); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); + +function loadConfig(configPath: string) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + config.l1RpcUrl = config.l1RpcUrl ?? DEFAULTS.l1RpcUrl; + config.l1FunderKey = config.l1FunderKey ?? DEFAULTS.l1FunderKey; + return config; +} + +async function claimFeeJuiceOnL2(node: AztecNode, feeJuiceClaim, wallet: EmbeddedWallet, fpcAddress: AztecAddress, fpcDeployer: AztecAddress, paymentMethod: SponsoredFeePaymentMethod) { + // Wait for the L1->L2 bridge message and claim the FJ to credit the FPC's balance. + console.log('\nWaiting for L1->L2 message sync...'); + await waitForL1ToL2MessageReady(node, Fr.fromHexString(feeJuiceClaim.messageHash), { timeoutSeconds: 120 }); + console.log('Message ready'); + + console.log('Claiming fee juice on L2 for FPC...'); + await FeeJuiceContract.at(wallet).methods + .claim(fpcAddress, feeJuiceClaim.claimAmount, feeJuiceClaim.claimSecret, feeJuiceClaim.messageLeafIndex) + .send({ from: fpcDeployer, fee: { paymentMethod } }); + console.log('FPC funded!'); +} + +// Auxiliaries + +function updateNetworkConfigFile(config: any, fpcAddress: AztecAddress, secretKey: Fr, signedUpFunctions: ResolvedSignup[], configPath: string) { + config.subscriptionFPC = { + address: fpcAddress.toString(), + secretKey: secretKey.toString(), + functions: buildFunctionsMap(signedUpFunctions), + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log(`\nUpdated ${configPath} with subscriptionFPC config.`); +} + +async function bridgeTokens(node: AztecNode, l1RpcUrl: string, l1FunderKey: string, fpcAddress: AztecAddress) { + const l1Client = createExtendedL1Client([l1RpcUrl], l1FunderKey, foundry); + const portalManager = await L1FeeJuicePortalManager.new(node, l1Client, createLogger('bridge')); + + const bridgeAmount: bigint = BigInt('1000000000000000000000'); // 1000 FJ + console.log(`\nBridging ${bridgeAmount} wei of fee juice to FPC...`); + // When mint=true, bridgeTokensPublic must match the exact bridgeAmount. + const claim = await portalManager.bridgeTokensPublic(fpcAddress, bridgeAmount, true); + console.log('L1 bridge tx mined.'); + return claim; +} + +/** + * Executes sign_up transactions on the SubscriptionFPC for each resolved signup. + * Must be called by the FPC admin with a working payment method. + */ +async function executeFpcSignUps( + fpcAddress: AztecAddress, + fpcDeployer: AztecAddress, + wallet: EmbeddedWallet, + paymentMethod: SponsoredFeePaymentMethod, + contracts: Record, +): Promise { + // Sign up functions so users can subscribe. These L2 txs also advance the L2 chain, + // which helps the sequencer include the pending L1->L2 bridge message. + const functionsToSignupToFpc = await resolveFpcSignups( + DEFAULTS.fpcSignups, + contracts, + DEFAULTS.fpcSignupDefaults, + ); + + const fpc = SubscriptionFPCContract.at(fpcAddress, wallet); + + for (const { addressKey, contractAddress, functionName, selector, maxUses, maxFee, maxUsers } of functionsToSignupToFpc) { + console.log(`\nSigning up ${addressKey}.${functionName} at index 0...`); + await fpc.methods + .sign_up(contractAddress, selector, 0, maxUses, maxFee, maxUsers) + .send({ from: fpcDeployer, fee: { paymentMethod } }); + console.log(`${addressKey}.${functionName} sign_up done!`); + } + + return functionsToSignupToFpc; +} + +/** + * Deploys a new SubscriptionFPC with fresh keys. The secret key is generated during + * deployment and must be persisted (clients need it to decrypt the FPC's slot notes). + */ +async function deploySubscriptionFpc( + wallet: EmbeddedWallet, + deployer: AztecAddress, + paymentMethod: SponsoredFeePaymentMethod, +): Promise<{ address: AztecAddress; secretKey: Fr }> { + console.log('Deploying SubscriptionFPC...'); + const { deployment, secretKey } = await SubscriptionFPC.deployWithKeys(wallet, deployer); + const receipt = await deployment.send({ from: deployer, fee: { paymentMethod } }); + const address = receipt.contract.address; + console.log('SubscriptionFPC deployed at:', address.toString()); + console.log('Secret key:', secretKey.toString()); + return { address, secretKey }; +} + +/** A sign-up spec with its selector computed and sponsorship params resolved. */ +interface ResolvedSignup { + addressKey: string; + contractAddress: AztecAddress; + functionName: string; + selector: FunctionSelector; + maxUses: number; + maxFee: bigint; + maxUsers: number; +} + +/** + * Resolves a list of FpcSignupSpecs into concrete (contractAddress, selector) tuples, + * merging per-spec overrides with the defaults. + */ +async function resolveFpcSignups( + specs: FpcSignupSpec[], + contracts: Record, + defaults: { maxUses: number; maxFee: bigint; maxUsers: number }, +): Promise { + return Promise.all( + specs.flatMap(spec => { + const fn = spec.artifact.functions.find(f => f.name === spec.functionName); + if (!fn) { + throw new Error(`Function ${spec.functionName} not found in artifact ${spec.artifact.name}`); + } + const maxUses = spec.maxUses ?? defaults.maxUses; + const maxFee = spec.maxFee ?? defaults.maxFee; + const maxUsers = spec.maxUsers ?? defaults.maxUsers; + return spec.contractAlias.map(async addressKey => { + const rawAddress = contracts[addressKey]; + if (!rawAddress) { + throw new Error(`Address key "${addressKey}" not found in config.contracts`); + } + const contractAddress = AztecAddress.fromString(rawAddress); + const selector = await FunctionSelector.fromNameAndParameters(fn.name, fn.parameters); + return { addressKey, contractAddress, functionName: spec.functionName, selector, maxUses, maxFee, maxUsers }; + }); + }), + ); +} + +/** + * Builds the subscriptionFPC.functions map from resolved signups: + * `{ contractAddress: { selectorHex: configIndex } }`. + */ +function buildFunctionsMap(resolved: ResolvedSignup[]): Record> { + const map: Record> = {}; + for (const { contractAddress, selector } of resolved) { + const key = contractAddress.toString(); + map[key] = map[key] ?? {}; + map[key][selector.toString()] = 0; + } + return map; +} + +async function deployAndRegisterSubscriptionFpc(node: AztecNode, wallet: EmbeddedWallet, deployer: AztecAddress, paymentMethod: SponsoredFeePaymentMethod) { + const { address: fpcAddress, secretKey } = await deploySubscriptionFpc(wallet, deployer, paymentMethod); + + // `deployWithKeys` deploys with derived public keys (so the PXE knows the contract's + // address + public keys), but never communicates the secret key to the PXE, it only + // returns it to us. `sign_up` emits SlotNotes at `self.storage.slots.at(self.address)` + // and calls `set_sender_for_tags(self.address)`, which requires the PXE to know the + // secret key corresponding to the FPC's address so it can compute tagging secrets. + // + // We add the secret key to the already-registered instance via the third arg of + // `registerContract`. Without this, `sign_up` later fails with "No public key registered for + // address 0x...". TODO: push this step into gregojuice's `deployWithKeys` upstream so + // callers don't need the follow-up. + const fpcInstance = await node.getContract(fpcAddress); + if (!fpcInstance) throw new Error('FPC contract not found on-chain after deploy'); + await wallet.registerContract(fpcInstance, SubscriptionFPCContractArtifact, secretKey); + + return { fpcAddress, secretKey }; +} diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 8c1f43f..bf873cd 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; -import { TokenContract } from '@aztec/noir-contracts.js/Token'; +import { TokenContract } from '../contracts/target/Token.ts'; import { AMMContract } from '../contracts/target/AMM.ts'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { Fr } from '@aztec/foundation/curves/bn254'; diff --git a/scripts/mint.ts b/scripts/mint.ts index c90e8e2..ffb2b84 100644 --- a/scripts/mint.ts +++ b/scripts/mint.ts @@ -11,7 +11,7 @@ import fs from 'fs'; import path from 'path'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { TokenContract } from '@aztec/noir-contracts.js/Token'; +import { TokenContract, TokenContractArtifact } from '../contracts/target/Token.ts'; import { BatchCall } from '@aztec/aztec.js/contracts'; import { parseNetwork, parseAddressList, NETWORK_URLS, setupWallet, getOrCreateDeployer } from './utils.ts'; @@ -57,7 +57,6 @@ async function main() { 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), diff --git a/src/App.tsx b/src/App.tsx index 45d8b4e..abd8a4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,14 @@ -import { ThemeProvider, CssBaseline, Container, Box, Typography } from '@mui/material'; +import { useState, useEffect } from 'react'; +import { ThemeProvider, CssBaseline, Container, Box, Typography, Tabs, Tab, Snackbar } from '@mui/material'; import { theme } from './theme'; import { GregoSwapLogo } from './components/GregoSwapLogo'; import { WalletChip } from './components/WalletChip'; import { NetworkSwitcher } from './components/NetworkSwitcher'; import { FooterInfo } from './components/FooterInfo'; import { SwapContainer } from './components/swap'; +import { SendContainer } from './components/send/SendContainer'; +import { ClaimPage } from './components/claim/ClaimPage'; +import { isClaimRoute } from './services/offchainLinkService'; import { useWallet } from './contexts/wallet'; import { useOnboarding } from './contexts/onboarding'; import { OnboardingModal } from './components/OnboardingModal'; @@ -20,17 +24,30 @@ const ProfilePanel = import.meta.env.DEV : null; export function App() { + const [activeTab, setActiveTab] = useState(0); + const [addressCopied, setAddressCopied] = useState(false); + const [onClaimRoute, setOnClaimRoute] = useState(isClaimRoute); const { disconnectWallet, setCurrentAddress, currentAddress, error: walletError, isLoading: walletLoading } = useWallet(); const { isOnboardingModalOpen, startOnboarding, resetOnboarding, status: onboardingStatus } = useOnboarding(); + // Re-evaluate the claim route whenever the URL hash changes so that pasting a claim + // link into an already-loaded tab (or clicking an in-app link) routes correctly. + useEffect(() => { + const handler = () => setOnClaimRoute(isClaimRoute()); + window.addEventListener('hashchange', handler); + return () => window.removeEventListener('hashchange', handler); + }, []); + const isOnboarded = onboardingStatus === 'completed'; - const handleWalletClick = () => { - // If already onboarded, start a new onboarding flow to change wallet + const handleWalletClick = async () => { + // If connected, copy the address. Otherwise start onboarding. if (isOnboarded && currentAddress) { - resetOnboarding(); + await navigator.clipboard.writeText(currentAddress.toString()); + setAddressCopied(true); + return; } - startOnboarding(); // Start onboarding when clicked from wallet chip + startOnboarding(); }; const handleDisconnect = async () => { @@ -75,63 +92,97 @@ export function App() { onClick={handleWalletClick} onDisconnect={handleDisconnect} /> + setAddressCopied(false)} + message="Address copied!" + /> - {/* Header */} - - - - - - Swap GregoCoin for GregoCoinPremium - - - - {/* Swap Interface */} - - - {/* Wallet Error Display */} - {walletError && ( - - - - Wallet Connection Error - - - {walletError} + {onClaimRoute ? ( + { + setActiveTab(1); // land on the Send tab after claiming + window.location.hash = ''; + }} + /> + ) : ( + <> + {/* Header */} + + + + + + Swap GregoCoin for GregoCoinPremium - - )} - {/* Loading Display */} - {walletLoading && !walletError && ( - - setActiveTab(value)} + centered sx={{ - p: 3, - backgroundColor: 'rgba(212, 255, 40, 0.05)', - border: '1px solid rgba(212, 255, 40, 0.2)', - borderRadius: 1, - textAlign: 'center', + mb: 3, + '& .MuiTab-root': { color: 'text.secondary', fontWeight: 600 }, + '& .Mui-selected': { color: 'primary.main' }, + '& .MuiTabs-indicator': { backgroundColor: 'primary.main' }, }} > - - Connecting to network... - - - - )} + + + + + {/* Tab Content */} + {activeTab === 0 && } + {activeTab === 1 && } - {/* Footer Info */} - + {/* Wallet Error Display */} + {walletError && ( + + + + Wallet Connection Error + + + {walletError} + + + + )} + + {/* Loading Display */} + {walletLoading && !walletError && ( + + + + Connecting to network... + + + + )} + + {/* Footer Info */} + + + )} diff --git a/src/app-entry.tsx b/src/app-entry.tsx index c8cb4b1..f7e705f 100644 --- a/src/app-entry.tsx +++ b/src/app-entry.tsx @@ -5,6 +5,7 @@ import { NetworkProvider } from './contexts/network/NetworkContext'; import { WalletProvider } from './contexts/wallet/WalletContext'; import { ContractsProvider } from './contexts/contracts/ContractsContext'; import { SwapProvider } from './contexts/swap/SwapContext'; +import { SendProvider } from './contexts/send/SendContext'; import { OnboardingProvider } from './contexts/onboarding/OnboardingContext'; createRoot(document.getElementById('root')!).render( @@ -14,7 +15,9 @@ createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/components/claim/ClaimPage.tsx b/src/components/claim/ClaimPage.tsx new file mode 100644 index 0000000..0afb2d8 --- /dev/null +++ b/src/components/claim/ClaimPage.tsx @@ -0,0 +1,128 @@ +import { Box, Typography, Button, Alert, CircularProgress, Chip } from '@mui/material'; +import { useEffect, useState, useCallback } from 'react'; +import { Fr } from '@aztec/aztec.js/fields'; +import { AztecAddress } from '@aztec/aztec.js/addresses'; +import { extractClaimPayload, type TransferLink } from '../../services/offchainLinkService'; +import { ClaimProgress } from './ClaimProgress'; +import { ClaimSuccess } from './ClaimSuccess'; +import { GregoSwapLogo } from '../GregoSwapLogo'; +import { useContracts } from '../../contexts/contracts'; +import { useWallet } from '../../contexts/wallet'; + +type ClaimState = + | { phase: 'decoding' } + | { phase: 'preview'; data: TransferLink } + | { phase: 'claiming'; data: TransferLink } + | { phase: 'verifying'; data: TransferLink } + | { phase: 'claimed'; data: TransferLink; verified: boolean } + | { phase: 'error'; message: string }; + +interface ClaimPageProps { + onClaimComplete: () => void; +} + +export function ClaimPage({ onClaimComplete }: ClaimPageProps) { + const [state, setState] = useState({ phase: 'decoding' }); + const { claimOffchainTransfer, registerBaseContracts, fetchBalances, isLoadingContracts } = useContracts(); + const { wallet, currentAddress } = useWallet(); + + // Step 1: Decode the link on mount + useEffect(() => { + const data = extractClaimPayload(); + if (!data) { + setState({ phase: 'error', message: 'Invalid or missing claim link.' }); + return; + } + setState({ phase: 'preview', data }); + }, []); + + // Step 2: Execute the claim + const doClaim = useCallback(async () => { + if (state.phase !== 'preview') return; + const { data } = state; + setState({ phase: 'claiming', data }); + + try { + // Ensure contracts are registered + if (wallet && !isLoadingContracts) { + try { await registerBaseContracts(); } catch { /* may already be registered */ } + } + + if (!wallet || !currentAddress) { + setState({ phase: 'error', message: 'No wallet available. Please refresh and try again.' }); + return; + } + + // Get balance before claim (for verification) + let balanceBefore = 0n; + try { + const [gc, gcp] = await fetchBalances(); + balanceBefore = data.token === 'gc' ? gc : gcp; + } catch { /* new wallet may have no balance */ } + + // Reconstruct Fr values and call offchain_receive + const tokenKey = data.token === 'gc' ? 'gregoCoin' as const : 'gregoCoinPremium' as const; + + await claimOffchainTransfer(tokenKey, { + ciphertext: data.payload.map((s: string) => Fr.fromString(s)), + recipient: AztecAddress.fromString(data.recipient), + tx_hash: Fr.fromString(data.txHash), + anchor_block_timestamp: BigInt(data.anchorBlockTimestamp), + }); + + setState({ phase: 'verifying', data }); + + // Verify balance + const [gcAfter, gcpAfter] = await fetchBalances(); + const balanceAfter = data.token === 'gc' ? gcAfter : gcpAfter; + const received = balanceAfter - balanceBefore; + const expectedAmount = BigInt(Math.round(parseFloat(data.amount))); + const verified = received >= expectedAmount; + + setState({ phase: 'claimed', data, verified }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Claim failed. Please try again.'; + setState({ phase: 'error', message }); + } + }, [state, wallet, currentAddress, isLoadingContracts, registerBaseContracts, fetchBalances, claimOffchainTransfer]); + + // After a successful claim, return to the main app and land on the Send tab. + // We just clear the hash and call the parent's callback — no reload, so the + // user's session (wallet, onboarding, contracts) is preserved. + const handleGoToSend = onClaimComplete; + + const tokenName = (t: string) => (t === 'gc' ? 'GregoCoin' : 'GregoCoinPremium'); + + return ( + + + + + + + + {state.phase === 'decoding' && ( + + )} + {state.phase === 'preview' && ( + + Someone sent you + + + {state.data.amount} {tokenName(state.data.token)} + + + + + + )} + {state.phase === 'claiming' && } + {state.phase === 'verifying' && } + {state.phase === 'claimed' && ( + + )} + {state.phase === 'error' && {state.message}} + + + ); +} diff --git a/src/components/claim/ClaimProgress.tsx b/src/components/claim/ClaimProgress.tsx new file mode 100644 index 0000000..fb9baf5 --- /dev/null +++ b/src/components/claim/ClaimProgress.tsx @@ -0,0 +1,21 @@ +import { Box, Typography, CircularProgress } from '@mui/material'; + +type ClaimPhase = 'claiming' | 'verifying'; + +interface ClaimProgressProps { + phase: ClaimPhase; +} + +const phaseMessages: Record = { + claiming: 'Claiming tokens...', + verifying: 'Verifying amount...', +}; + +export function ClaimProgress({ phase }: ClaimProgressProps) { + return ( + + + {phaseMessages[phase]} + + ); +} diff --git a/src/components/claim/ClaimSuccess.tsx b/src/components/claim/ClaimSuccess.tsx new file mode 100644 index 0000000..6296dbe --- /dev/null +++ b/src/components/claim/ClaimSuccess.tsx @@ -0,0 +1,23 @@ +import { Box, Typography, Button, Chip } from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; + +interface ClaimSuccessProps { + amount: string; + tokenName: string; + verified: boolean; + onGoToSend: () => void; +} + +export function ClaimSuccess({ amount, tokenName, verified, onGoToSend }: ClaimSuccessProps) { + return ( + + + Tokens Claimed! + + {amount} {tokenName} + + + + + ); +} diff --git a/src/components/send/LinkDisplay.tsx b/src/components/send/LinkDisplay.tsx new file mode 100644 index 0000000..46a5861 --- /dev/null +++ b/src/components/send/LinkDisplay.tsx @@ -0,0 +1,41 @@ +import { Box, Typography, Button, IconButton, Snackbar } from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { QRCodeSVG } from 'qrcode.react'; +import { useState } from 'react'; + +interface LinkDisplayProps { + link: string; + amount: string; + token: 'gc' | 'gcp'; + recipient: string; + onReset: () => void; +} + +export function LinkDisplay({ link, amount, token, recipient, onReset }: LinkDisplayProps) { + const [copied, setCopied] = useState(false); + const tokenName = token === 'gc' ? 'GregoCoin' : 'GregoCoinPremium'; + + const handleCopy = async () => { + await navigator.clipboard.writeText(link); + setCopied(true); + }; + + return ( + + Sent! + + {amount} {tokenName} → {recipient.slice(0, 8)}...{recipient.slice(-4)} + + + {link} + + + + + + Scan to claim + + setCopied(false)} message="Link copied!" /> + + ); +} diff --git a/src/components/send/SendContainer.tsx b/src/components/send/SendContainer.tsx new file mode 100644 index 0000000..81f6fe8 --- /dev/null +++ b/src/components/send/SendContainer.tsx @@ -0,0 +1,96 @@ +import { Box, Alert, Dialog, DialogTitle, DialogContent, CircularProgress, Typography } from '@mui/material'; +import { useSend } from '../../contexts/send'; +import { useWallet } from '../../contexts/wallet'; +import { useContracts } from '../../contexts/contracts'; +import { SendForm } from './SendForm'; +import { SendProgress } from './SendProgress'; +import { LinkDisplay } from './LinkDisplay'; +import { SentHistory } from './SentHistory'; +import { DripPasswordInput } from '../onboarding/DripPasswordInput'; +import { parseDripError } from '../../services/contractService'; +import { useEffect, useState } from 'react'; + +type FaucetPhase = 'idle' | 'registering' | 'awaiting_password' | 'dripping'; + +export function SendContainer() { + const { phase, error, generatedLink, token, amount, recipientAddress, dismissError, reset } = useSend(); + const { currentAddress } = useWallet(); + const { fetchBalances, registerDripContracts, drip } = useContracts(); + const [balances, setBalances] = useState<{ gc: bigint | null; gcp: bigint | null }>({ gc: null, gcp: null }); + const [faucetPhase, setFaucetPhase] = useState('idle'); + const [faucetError, setFaucetError] = useState(null); + + useEffect(() => { + if (currentAddress) { + fetchBalances().then(([gc, gcp]) => setBalances({ gc, gcp })); + } + }, [currentAddress, fetchBalances]); + + useEffect(() => { + if (phase === 'link_ready' && currentAddress) { + fetchBalances().then(([gc, gcp]) => setBalances({ gc, gcp })); + } + }, [phase, currentAddress, fetchBalances]); + + const handleOpenFaucet = async () => { + setFaucetError(null); + setFaucetPhase('registering'); + try { + await registerDripContracts(); + setFaucetPhase('awaiting_password'); + } catch (err) { + setFaucetError(err instanceof Error ? err.message : 'Failed to register drip contracts'); + setFaucetPhase('idle'); + } + }; + + const handleDripSubmit = async (password: string) => { + if (!currentAddress) return; + setFaucetPhase('dripping'); + try { + await drip(password, currentAddress); + const [gc, gcp] = await fetchBalances(); + setBalances({ gc, gcp }); + setFaucetPhase('idle'); + } catch (err) { + setFaucetError(parseDripError(err)); + setFaucetPhase('awaiting_password'); + } + }; + + const closeDialog = () => { + if (faucetPhase === 'dripping') return; // don't allow close while in-flight + setFaucetPhase('idle'); + setFaucetError(null); + }; + + return ( + + {phase === 'link_ready' && generatedLink ? ( + + ) : ( + <> + + + + )} + {error && {error}} + {faucetError && setFaucetError(null)} sx={{ mt: 2 }}>{faucetError}} + {currentAddress && } + + + Get tokens from faucet + + {faucetPhase === 'dripping' ? ( + + + Claiming tokens... + + ) : ( + + )} + + + + ); +} diff --git a/src/components/send/SendForm.tsx b/src/components/send/SendForm.tsx new file mode 100644 index 0000000..af8c29d --- /dev/null +++ b/src/components/send/SendForm.tsx @@ -0,0 +1,50 @@ +import { Box, TextField, Typography, ToggleButton, ToggleButtonGroup, Button } from '@mui/material'; +import WaterDropIcon from '@mui/icons-material/WaterDrop'; +import { useSend } from '../../contexts/send'; + +interface SendFormProps { + balance: { gc: bigint | null; gcp: bigint | null }; + onRequestFaucet: () => void; + faucetBusy: boolean; +} + +export function SendForm({ balance, onRequestFaucet, faucetBusy }: SendFormProps) { + const { token, recipientAddress, amount, phase, setToken, setRecipientAddress, setAmount, canSend, executeSend } = useSend(); + const isSending = phase === 'sending' || phase === 'generating_link'; + const currentBalance = token === 'gc' ? balance.gc : balance.gcp; + const selectedTokenIsEmpty = currentBalance === 0n; + + return ( + + + + Token + + v && setToken(v)} size="small" fullWidth disabled={isSending}> + GregoCoin + GregoCoinPremium + + + setRecipientAddress(e.target.value)} fullWidth disabled={isSending} size="small" /> + + setAmount(e.target.value)} fullWidth disabled={isSending} size="small" + slotProps={{ input: { endAdornment: currentBalance !== null ? Balance: {currentBalance.toString()} : null } }} /> + + {selectedTokenIsEmpty && ( + + )} + + + ); +} diff --git a/src/components/send/SendProgress.tsx b/src/components/send/SendProgress.tsx new file mode 100644 index 0000000..945c4e3 --- /dev/null +++ b/src/components/send/SendProgress.tsx @@ -0,0 +1,22 @@ +import { Box, Typography, CircularProgress } from '@mui/material'; +import type { SendPhase } from '../../contexts/send'; + +interface SendProgressProps { + phase: SendPhase; +} + +const phaseMessages: Record = { + sending: 'Sending transaction...', + generating_link: 'Generating claim link...', +}; + +export function SendProgress({ phase }: SendProgressProps) { + const message = phaseMessages[phase]; + if (!message) return null; + return ( + + + {message} + + ); +} diff --git a/src/components/send/SentHistory.tsx b/src/components/send/SentHistory.tsx new file mode 100644 index 0000000..3ce56bb --- /dev/null +++ b/src/components/send/SentHistory.tsx @@ -0,0 +1,68 @@ +import { Box, Typography, IconButton, Snackbar, Chip } from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useState } from 'react'; +import { getSentTransfers, type SentTransfer } from '../../services/sentHistoryService'; + +interface SentHistoryProps { + senderAddress: string; +} + +function timeAgo(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function StatusChip({ status }: { status: SentTransfer['status'] }) { + if (status === 'confirmed') return null; + const color = status === 'pending' ? 'warning' : 'error'; + return ; +} + +export function SentHistory({ senderAddress }: SentHistoryProps) { + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(false); + const transfers = getSentTransfers(senderAddress); + + if (transfers.length === 0) return null; + + const visibleTransfers = expanded ? transfers : transfers.slice(0, 3); + const hasMore = transfers.length > 3; + + const handleCopy = async (link: string) => { + await navigator.clipboard.writeText(link); + setCopied(true); + }; + + return ( + + Sent transfers + {visibleTransfers.map(transfer => ( + + + {transfer.amount} {transfer.token === 'gc' ? 'GC' : 'GCP'} + → {transfer.recipient.slice(0, 8)}...{transfer.recipient.slice(-4)} + + + + {timeAgo(transfer.createdAt)} + handleCopy(transfer.link)}> + + + ))} + {hasMore && ( + + setExpanded(!expanded)} sx={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: '0.2s' }}> + + + + )} + setCopied(false)} message="Link copied!" /> + + ); +} diff --git a/src/config/networks/testnet.json b/src/config/networks/testnet.json index eeb7394..c818e85 100644 --- a/src/config/networks/testnet.json +++ b/src/config/networks/testnet.json @@ -4,26 +4,26 @@ "chainId": "11155111", "rollupVersion": "4127419662", "contracts": { - "gregoCoin": "0x2a6921ec2b9d6c463c427e207f78b2bbf0b36d5878923273ed6a9adc26d9bc2b", - "gregoCoinPremium": "0x2c39517fa2194b5cc0fc82737745bffd500210b3d429cdaf3330fd6b7ca11373", - "amm": "0x181b971d54d29179cf25b3b69f79b028ea991fbf97cc8d4065d64bc53018932a", - "liquidityToken": "0x2d49c7bcbb1b42cebfbb99dbb47ff3468fc3256141d286b685121971ac6e14df", - "pop": "0x0cc4ceb7cf7cc514ea8fbc5dc7866bedfca6d42bbe4c20a4ad16f4ecb21d4c84", + "gregoCoin": "0x0bb7e92d4e23984535a60984dfdb5bd4d7a5d92aed825b06dd53a6736cb40524", + "gregoCoinPremium": "0x181fe4114e443864f8a71da189544c195bc6982c4bab1407fd16f53c93d3ac0f", + "amm": "0x2b4ddd8a439bac34b9b319d348f177a081fab84d0e026e36681fb6ccaedbdafd", + "liquidityToken": "0x24bfd8325e21e4fcda70ae33ea6161082a7ba4fd30fbf63f9f66e20bf09c5548", + "pop": "0x2b5ee301889d8fc743ad784698078143524584d3e23eff3aaf3c111af68a36cd", "sponsoredFPC": "0x254082b62f9108d044b8998f212bb145619d91bfcd049461d74babb840181257", - "salt": "0x21ccf858cdb7ba2e24cb084cd2853658f39e30951f0ed58a6ca57e848427804c" + "salt": "0x1c1b303180f8a60f3f8d7fa1ada325a619e2972b83fb8da936be0a4bde382bb7" }, "deployer": { "address": "0x12952c3d8a4116782ee85b93c634afbd4cdf2beabedd8258e89737616c269c33" }, - "deployedAt": "2026-04-16T07:16:45.008Z", + "deployedAt": "2026-04-17T11:19:07.488Z", "subscriptionFPC": { - "address": "0x23fae3f4cdad2f6b36838eaf5020dd6817ff736ebcc2c7f7fa5a9e8f09b57f2f", - "secretKey": "0x061f4db024da8aa7a3eea724beee9dd6c78e4b51d2bca3bf95f8a5d1da94e594", + "address": "0x2f72a0266d8610866d947ac5b6fa391ba2f7b629fe4513a4a46f775a13f9b543", + "secretKey": "0x09b3922c228caa2fd9459b69c2ad902c9169206885b4fbc4877650d229ad71fb", "functions": { - "0x0cc4ceb7cf7cc514ea8fbc5dc7866bedfca6d42bbe4c20a4ad16f4ecb21d4c84": { + "0x2b5ee301889d8fc743ad784698078143524584d3e23eff3aaf3c111af68a36cd": { "0xa539bd29": 0 }, - "0x181b971d54d29179cf25b3b69f79b028ea991fbf97cc8d4065d64bc53018932a": { + "0x2b4ddd8a439bac34b9b319d348f177a081fab84d0e026e36681fb6ccaedbdafd": { "0xfd228669": 0 } } diff --git a/src/contexts/contracts/ContractsContext.tsx b/src/contexts/contracts/ContractsContext.tsx index 2eaf7dc..badce03 100644 --- a/src/contexts/contracts/ContractsContext.tsx +++ b/src/contexts/contracts/ContractsContext.tsx @@ -5,13 +5,16 @@ import { createContext, useContext, useEffect, type ReactNode, useCallback } from 'react'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { Fr } from '@aztec/foundation/curves/bn254'; import type { TxReceipt } from '@aztec/stdlib/tx'; import type { AMMContract } from '../../../contracts/target/AMM'; +import type { OffchainMessage } from '@aztec/aztec.js/contracts'; import type { SubscriptionFPC } from '@gregojuice/contracts/subscription-fpc'; import { useWallet } from '../wallet'; import { useNetwork } from '../network'; import * as contractService from '../../services/contractService'; import { useContractsReducer } from './reducer'; +import { stat } from 'fs'; interface ContractsContextType { isLoadingContracts: boolean; @@ -29,6 +32,15 @@ interface ContractsContextType { fetchBalances: () => Promise<[bigint, bigint]>; simulateOnboardingQueries: () => Promise<[number, bigint, bigint]>; drip: (password: string, recipient: AztecAddress) => Promise; + sendOffchain: ( + tokenKey: 'gregoCoin' | 'gregoCoinPremium', + recipient: AztecAddress, + amount: bigint, + ) => Promise<{ receipt: TxReceipt; offchainMessages: OffchainMessage[] }>; + claimOffchainTransfer: ( + tokenKey: 'gregoCoin' | 'gregoCoinPremium', + message: { ciphertext: Fr[]; recipient: AztecAddress; tx_hash: Fr; anchor_block_timestamp: bigint }, + ) => Promise; } const ContractsContext = createContext(undefined); @@ -238,6 +250,50 @@ export function ContractsProvider({ children }: ContractsProviderProps) { [wallet, activeNetwork, state.contracts.pop, state.contracts.fpc], ); + // Execute offchain transfer (send with link) + const sendOffchain = useCallback( + async (tokenKey: 'gregoCoin' | 'gregoCoinPremium', recipient: AztecAddress, amount: bigint) => { + if ( + !wallet || + !currentAddress || + !state.contracts.gregoCoin || + !state.contracts.gregoCoinPremium || + !state.contracts.amm + ) { + throw new Error('Contracts not initialized'); + } + return contractService.executeTransferOffchain( + activeNetwork, + { + gregoCoin: state.contracts.gregoCoin, + gregoCoinPremium: state.contracts.gregoCoinPremium, + amm: state.contracts.amm, + fpc: state.contracts.fpc, + }, + tokenKey, + currentAddress, + recipient, + amount, + ); + }, + [wallet, activeNetwork, currentAddress, state.contracts], + ); + + // Claim an offchain transfer via offchain_receive + const claimOffchainTransfer = useCallback( + async ( + tokenKey: 'gregoCoin' | 'gregoCoinPremium', + message: { ciphertext: Fr[]; recipient: AztecAddress; tx_hash: Fr; anchor_block_timestamp: bigint }, + ) => { + if (!wallet || !currentAddress || !state.contracts.gregoCoin || !state.contracts.gregoCoinPremium) { + throw new Error('Contracts not initialized'); + } + const token = tokenKey === 'gregoCoin' ? state.contracts.gregoCoin : state.contracts.gregoCoinPremium; + await token.methods.offchain_receive([message]).simulate({ from: currentAddress }); + }, + [wallet, currentAddress, state.contracts], + ); + // Initialize contracts for embedded wallet useEffect(() => { async function initializeContracts() { @@ -273,6 +329,8 @@ export function ContractsProvider({ children }: ContractsProviderProps) { fetchBalances, simulateOnboardingQueries, drip, + sendOffchain, + claimOffchainTransfer, }; return {children}; diff --git a/src/contexts/contracts/reducer.ts b/src/contexts/contracts/reducer.ts index fd503b4..b313da6 100644 --- a/src/contexts/contracts/reducer.ts +++ b/src/contexts/contracts/reducer.ts @@ -3,7 +3,7 @@ * Manages contract instances and registration state */ -import type { TokenContract } from '@aztec/noir-contracts.js/Token'; +import type { TokenContract } from '../../../contracts/target/Token'; import type { AMMContract } from '../../../contracts/target/AMM'; import type { ProofOfPasswordContract } from '../../../contracts/target/ProofOfPassword'; import type { SubscriptionFPC } from '@gregojuice/contracts/subscription-fpc'; diff --git a/src/contexts/send/SendContext.tsx b/src/contexts/send/SendContext.tsx new file mode 100644 index 0000000..93bfd75 --- /dev/null +++ b/src/contexts/send/SendContext.tsx @@ -0,0 +1,125 @@ +/** + * Send Context + * Manages offchain transfer flow and link generation + */ + +import { createContext, useContext, type ReactNode, useCallback } from 'react'; +import { AztecAddress } from '@aztec/aztec.js/addresses'; +import { useSendReducer, type SendState, type SendPhase } from './reducer'; +import { useContracts } from '../contracts'; +import { useWallet } from '../wallet'; +import { useNetwork } from '../network'; +import { encodeTransferLink, type TransferLink } from '../../services/offchainLinkService'; +import { addSentTransfer } from '../../services/sentHistoryService'; + +interface SendContextType extends SendState { + setToken: (token: 'gc' | 'gcp') => void; + setRecipientAddress: (address: string) => void; + setAmount: (amount: string) => void; + startSend: () => void; + generatingLink: () => void; + linkReady: (link: string) => void; + sendError: (error: string) => void; + dismissError: () => void; + reset: () => void; + canSend: boolean; + executeSend: () => Promise; +} + +const SendContext = createContext(undefined); + +export function useSend() { + const context = useContext(SendContext); + if (context === undefined) { + throw new Error('useSend must be used within a SendProvider'); + } + return context; +} + +interface SendProviderProps { + children: ReactNode; +} + +export function SendProvider({ children }: SendProviderProps) { + const [state, actions] = useSendReducer(); + const { sendOffchain, isLoadingContracts } = useContracts(); + const { currentAddress } = useWallet(); + const { activeNetwork } = useNetwork(); + + const canSend = + !!state.amount && + parseFloat(state.amount) > 0 && + !!state.recipientAddress && + !isLoadingContracts && + !!currentAddress; + + const executeSend = useCallback(async () => { + if (!currentAddress || !state.recipientAddress || !state.amount) { + actions.sendError('Missing required fields'); + return; + } + + actions.startSend(); + + try { + const recipient = AztecAddress.fromString(state.recipientAddress); + const amount = BigInt(Math.round(parseFloat(state.amount))); + const tokenKey = state.token === 'gc' ? 'gregoCoin' as const : 'gregoCoinPremium' as const; + const contractAddress = activeNetwork.contracts[tokenKey]; + + const { receipt, offchainMessages } = await sendOffchain(tokenKey, recipient, amount); + + actions.generatingLink(); + + const recipientMessage = offchainMessages[0]; + if (!recipientMessage) { + throw new Error('No offchain message generated for recipient'); + } + + const linkData: TransferLink = { + token: state.token, + amount: state.amount, + recipient: state.recipientAddress, + contractAddress, + txHash: receipt.txHash.toString(), + anchorBlockTimestamp: recipientMessage.anchorBlockTimestamp.toString(), + payload: recipientMessage.payload.map((f: any) => f.toString()), + }; + + const link = encodeTransferLink(linkData); + actions.linkReady(link); + + addSentTransfer(currentAddress.toString(), { + id: receipt.txHash.toString(), + token: state.token, + amount: state.amount, + recipient: state.recipientAddress, + link, + createdAt: Date.now(), + status: 'confirmed', + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Send failed. Please try again.'; + actions.sendError(message); + } + }, [currentAddress, state.recipientAddress, state.amount, state.token, activeNetwork, sendOffchain, actions]); + + const value: SendContextType = { + ...state, + setToken: actions.setToken, + setRecipientAddress: actions.setRecipientAddress, + setAmount: actions.setAmount, + startSend: actions.startSend, + generatingLink: actions.generatingLink, + linkReady: actions.linkReady, + sendError: actions.sendError, + dismissError: actions.dismissError, + reset: actions.reset, + canSend, + executeSend, + }; + + return {children}; +} + +export type { SendPhase }; diff --git a/src/contexts/send/index.ts b/src/contexts/send/index.ts new file mode 100644 index 0000000..5da65e8 --- /dev/null +++ b/src/contexts/send/index.ts @@ -0,0 +1,2 @@ +export { SendProvider, useSend } from './SendContext'; +export type { SendPhase, SendState } from './reducer'; diff --git a/src/contexts/send/reducer.ts b/src/contexts/send/reducer.ts new file mode 100644 index 0000000..cbb896e --- /dev/null +++ b/src/contexts/send/reducer.ts @@ -0,0 +1,71 @@ +/** + * Send Reducer + * Manages send flow state and transaction phases + */ + +import { createReducerHook, type ActionsFrom } from '../utils'; + +// State +export type SendPhase = 'idle' | 'sending' | 'generating_link' | 'link_ready' | 'error'; + +export interface SendState { + token: 'gc' | 'gcp'; + recipientAddress: string; + amount: string; + phase: SendPhase; + error: string | null; + generatedLink: string | null; +} + +export const initialSendState: SendState = { + token: 'gc', + recipientAddress: '', + amount: '', + phase: 'idle', + error: null, + generatedLink: null, +}; + +// Actions (namespaced with 'send/') +export const sendActions = { + setToken: (token: 'gc' | 'gcp') => ({ type: 'send/SET_TOKEN' as const, token }), + setRecipientAddress: (address: string) => ({ type: 'send/SET_RECIPIENT' as const, address }), + setAmount: (amount: string) => ({ type: 'send/SET_AMOUNT' as const, amount }), + startSend: () => ({ type: 'send/START_SEND' as const }), + generatingLink: () => ({ type: 'send/GENERATING_LINK' as const }), + linkReady: (link: string) => ({ type: 'send/LINK_READY' as const, link }), + sendError: (error: string) => ({ type: 'send/SEND_ERROR' as const, error }), + dismissError: () => ({ type: 'send/DISMISS_ERROR' as const }), + reset: () => ({ type: 'send/RESET' as const }), +}; + +export type SendAction = ActionsFrom; + +// Reducer +export function sendReducer(state: SendState, action: SendAction): SendState { + switch (action.type) { + case 'send/SET_TOKEN': + return { ...state, token: action.token }; + case 'send/SET_RECIPIENT': + return { ...state, recipientAddress: action.address }; + case 'send/SET_AMOUNT': + return { ...state, amount: action.amount }; + case 'send/START_SEND': + return { ...state, phase: 'sending', error: null, generatedLink: null }; + case 'send/GENERATING_LINK': + return { ...state, phase: 'generating_link' }; + case 'send/LINK_READY': + return { ...state, phase: 'link_ready', generatedLink: action.link }; + case 'send/SEND_ERROR': + return { ...state, phase: 'error', error: action.error }; + case 'send/DISMISS_ERROR': + return { ...state, phase: 'idle', error: null }; + case 'send/RESET': + return { ...initialSendState }; + default: + return state; + } +} + +// Hook +export const useSendReducer = createReducerHook(sendReducer, sendActions, initialSendState); diff --git a/src/services/contractService.ts b/src/services/contractService.ts index 8d7fc7d..f844756 100644 --- a/src/services/contractService.ts +++ b/src/services/contractService.ts @@ -9,13 +9,13 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; import { AztecAddress as AztecAddressClass } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import { FunctionSelector } from '@aztec/aztec.js/abi'; -import { BatchCall, getContractInstanceFromInstantiationParams } from '@aztec/aztec.js/contracts'; +import { BatchCall, getContractInstanceFromInstantiationParams, type OffchainMessage } from '@aztec/aztec.js/contracts'; import { poseidon2Hash } from '@aztec/foundation/crypto/poseidon'; import { type FunctionCall, decodeFromAbi } from '@aztec/stdlib/abi'; import { ExecutionPayload } from '@aztec/stdlib/tx'; import { UtilityExecutionResult } from '@aztec/stdlib/tx'; import type { TxReceipt } from '@aztec/stdlib/tx'; -import type { TokenContract } from '@aztec/noir-contracts.js/Token'; +import type { TokenContract } from '../../contracts/target/Token'; import type { AMMContract } from '../../contracts/target/AMM'; import type { ProofOfPasswordContract } from '../../contracts/target/ProofOfPassword'; import { SubscriptionFPC } from '@gregojuice/contracts/subscription-fpc'; @@ -59,7 +59,7 @@ export async function registerSwapContracts( const contractSalt = Fr.fromString(network.contracts.salt); // Import contract artifacts - const { TokenContract, TokenContractArtifact } = await import('@aztec/noir-contracts.js/Token'); + const { TokenContract, TokenContractArtifact } = await import('../../contracts/target/Token'); const { AMMContract, AMMContractArtifact } = await import('../../contracts/target/AMM'); // Determine subscription FPC for sponsored swaps @@ -539,6 +539,73 @@ export async function executeDrip( return receipt; } +/** + * Execute an offchain token transfer. + * Sends tokens privately with offchain note delivery, self-delivers the sender's + * change note, and returns the recipient's offchain messages for link encoding. + */ +export async function executeTransferOffchain( + network: NetworkConfig, + contracts: SwapContracts, + tokenKey: 'gregoCoin' | 'gregoCoinPremium', + fromAddress: AztecAddress, + recipient: AztecAddress, + amount: bigint, +): Promise<{ receipt: TxReceipt; offchainMessages: OffchainMessage[] }> { + const subFPC = network.subscriptionFPC; + if (!subFPC) { + throw new Error('No subscriptionFPC configured for this network'); + } + + const fpc = contracts.fpc; + + const token = contracts[tokenKey]; + + const authwitNonce = Fr.random(); + const call = await token.methods + .transfer_in_private_deliver_offchain(fromAddress, recipient, amount, authwitNonce) + .getFunctionCall(); + + const configIndex = subFPC.functions[token.address.toString()]?.[call.selector.toString()]; + if (configIndex == null) { + throw new Error( + `No subscription config found for token ${token.address.toString()} selector ${call.selector.toString()}`, + ); + } + + const subscribed = hasSubscription(subFPC.address, configIndex, fromAddress.toString()); + + let txResult: { receipt: TxReceipt; offchainMessages: OffchainMessage[] }; + if (subscribed) { + txResult = await fpc.helpers.sponsor({ call, configIndex, userAddress: fromAddress }); + } else { + txResult = await fpc.helpers.subscribe({ call, configIndex, userAddress: fromAddress }); + markSubscribed(subFPC.address, configIndex, fromAddress.toString()); + } + + const { receipt, offchainMessages } = txResult; + + // Self-deliver sender's change note (manual until F-324 lands) + const senderMessages = offchainMessages.filter((msg: OffchainMessage) => msg.recipient.equals(fromAddress)); + if (senderMessages.length > 0) { + await token.methods + .offchain_receive( + senderMessages.map((msg: OffchainMessage) => ({ + ciphertext: msg.payload, + recipient: fromAddress, + tx_hash: receipt.txHash.hash, + anchor_block_timestamp: msg.anchorBlockTimestamp, + })), + ) + .simulate({ from: fromAddress }); + } + + // Filter and return recipient's messages for link encoding + const recipientMessages = offchainMessages.filter((msg: OffchainMessage) => msg.recipient.equals(recipient)); + + return { receipt, offchainMessages: recipientMessages }; +} + /** * Parses a drip error into a user-friendly message */ @@ -564,3 +631,15 @@ export function parseDripError(error: unknown): string { return message; } + +/** + * Parses a send (offchain transfer) error into a user-friendly message + */ +export function parseSendError(error: unknown): string { + if (!(error instanceof Error)) return 'Send failed. Please try again.'; + const msg = error.message; + if (msg.includes('Balance too low')) return 'Insufficient token balance'; + if (msg.includes('User denied') || msg.includes('rejected')) return 'Transaction was rejected in wallet'; + if (msg.includes('invalid') && msg.includes('address')) return 'Invalid recipient address'; + return msg; +} diff --git a/src/services/offchainLinkService.ts b/src/services/offchainLinkService.ts new file mode 100644 index 0000000..17d47df --- /dev/null +++ b/src/services/offchainLinkService.ts @@ -0,0 +1,46 @@ +/** + * Offchain Link Service + * Encodes/decodes offchain transfer messages into shareable URLs + */ + +export interface TransferLink { + token: 'gc' | 'gcp'; + amount: string; + recipient: string; + contractAddress: string; + txHash: string; + anchorBlockTimestamp: string; + payload: string[]; +} + +export function encodeTransferLink(data: TransferLink): string { + const json = JSON.stringify(data); + const encoded = btoa(json) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `${window.location.origin}/#/claim/${encoded}`; +} + +export function decodeTransferLink(encoded: string): TransferLink { + const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + const json = atob(base64); + return JSON.parse(json) as TransferLink; +} + +export function extractClaimPayload(): TransferLink | null { + const hash = window.location.hash; + const prefix = '#/claim/'; + if (!hash.startsWith(prefix)) { + return null; + } + try { + return decodeTransferLink(hash.slice(prefix.length)); + } catch { + return null; + } +} + +export function isClaimRoute(): boolean { + return window.location.hash.startsWith('#/claim/'); +} diff --git a/src/services/sentHistoryService.ts b/src/services/sentHistoryService.ts new file mode 100644 index 0000000..9f33d42 --- /dev/null +++ b/src/services/sentHistoryService.ts @@ -0,0 +1,49 @@ +/** + * Sent History Service + * localStorage CRUD for tracking sent offchain transfers + */ + +export type SentTransferStatus = 'pending' | 'confirmed' | 'expired'; + +export interface SentTransfer { + id: string; + token: 'gc' | 'gcp'; + amount: string; + recipient: string; + link: string; + createdAt: number; + status: SentTransferStatus; +} + +function storageKey(senderAddress: string): string { + return `gregoswap_sent_transfers_${senderAddress}`; +} + +export function getSentTransfers(senderAddress: string): SentTransfer[] { + try { + const raw = localStorage.getItem(storageKey(senderAddress)); + if (!raw) return []; + return JSON.parse(raw) as SentTransfer[]; + } catch { + return []; + } +} + +export function addSentTransfer(senderAddress: string, transfer: SentTransfer): void { + const existing = getSentTransfers(senderAddress); + existing.unshift(transfer); + localStorage.setItem(storageKey(senderAddress), JSON.stringify(existing)); +} + +export function updateSentTransferStatus( + senderAddress: string, + transferId: string, + status: SentTransferStatus, +): void { + const transfers = getSentTransfers(senderAddress); + const index = transfers.findIndex(t => t.id === transferId); + if (index !== -1) { + transfers[index].status = status; + localStorage.setItem(storageKey(senderAddress), JSON.stringify(transfers)); + } +} diff --git a/vite.config.ts b/vite.config.ts index a628fe6..8f406b4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -135,7 +135,7 @@ export default defineConfig(({ command, mode }) => { chunkSizeValidator([ { pattern: /assets\/index-.*\.js$/, - maxSizeKB: 1600, + maxSizeKB: 1700, description: 'Main entrypoint, hard limit', }, { diff --git a/yarn.lock b/yarn.lock index e672dd2..c153bd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6117,6 +6117,7 @@ __metadata: eslint-plugin-react-refresh: "npm:^0.4.18" globals: "npm:^15.14.0" prettier: "npm:^3.5.3" + qrcode.react: "npm:^4.2.0" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" react-dropzone: "npm:^14.3.5" @@ -8340,6 +8341,15 @@ __metadata: languageName: node linkType: hard +"qrcode.react@npm:^4.2.0": + version: 4.2.0 + resolution: "qrcode.react@npm:4.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248 + languageName: node + linkType: hard + "qs@npm:^6.12.3": version: 6.14.0 resolution: "qs@npm:6.14.0"