diff --git a/web-wallet/src/__mocks__/TransactionBuilder.js b/web-wallet/src/__mocks__/TransactionBuilder.js deleted file mode 100644 index dac66180a..000000000 --- a/web-wallet/src/__mocks__/TransactionBuilder.js +++ /dev/null @@ -1,72 +0,0 @@ -import { Gas } from "$lib/vendor/w3sper.js/src/network/gas"; - -/** - * @typedef {Uint8Array | import("$lib/vendor/w3sper.js/src/profile").Profile["address"]} Identifier - */ - -class TransactionBuilderMock { - #amount = 0n; - #bookkeeper; - - /** @type {Identifier} */ - #from = new Uint8Array(); - - #gas; - - #obfuscated = false; - - /** @type {Identifier} */ - #to = new Uint8Array(); - - /** @param {import("$lib/vendor/w3sper.js/src/bookkeeper").Bookkeeper} bookkeeper */ - constructor(bookkeeper) { - this.#bookkeeper = bookkeeper; - this.#gas = new Gas(); - } - - /** @param {bigint} value */ - amount(value) { - this.#amount = value; - - return this; - } - - /** @param {Identifier} identifier */ - from(identifier) { - this.#from = identifier; - - return this; - } - - /** @param {Gas} value */ - gas(value) { - this.#gas = value; - - return this; - } - - obfuscated() { - this.#obfuscated = true; - - return this; - } - - /** @param {Identifier} identifier */ - to(identifier) { - this.#to = identifier; - - return this; - } - - toJSON() { - return { - amount: this.#amount, - from: this.#from.toString(), - gas: this.#gas, - obfuscated: this.#obfuscated, - to: this.#to, - }; - } -} - -export default TransactionBuilderMock; diff --git a/web-wallet/src/__mocks__/Transactions.js b/web-wallet/src/__mocks__/Transactions.js index 06e35cdf4..15d4756ad 100644 --- a/web-wallet/src/__mocks__/Transactions.js +++ b/web-wallet/src/__mocks__/Transactions.js @@ -9,7 +9,8 @@ afterAll(() => { }); }); -const deferredRemove = Promise.withResolvers(); +/** @type {PromiseWithResolvers} */ +let deferredRemove; class FakeRuesScope { #id = ""; @@ -45,6 +46,7 @@ class FakeRuesScope { } removed() { + deferredRemove = Promise.withResolvers(); return deferredRemove.promise; } diff --git a/web-wallet/src/lib/stores/__tests__/walletStore.spec.js b/web-wallet/src/lib/stores/__tests__/walletStore.spec.js index 76f81b2d7..11222bdda 100644 --- a/web-wallet/src/lib/stores/__tests__/walletStore.spec.js +++ b/web-wallet/src/lib/stores/__tests__/walletStore.spec.js @@ -14,7 +14,6 @@ import { Network, ProfileGenerator, } from "$lib/vendor/w3sper.js/src/mod"; -import * as b58 from "$lib/vendor/w3sper.js/src/b58"; import { generateMnemonic } from "bip39"; import walletCache from "$lib/wallet-cache"; @@ -255,12 +254,6 @@ describe("Wallet store", async () => { }); describe("Wallet store services", () => { - /** @type {string} */ - let fromAddress; - - /** @type {string} */ - let fromAccount; - const toPhoenix = "4ZH3oyfTuMHyWD1Rp4e7QKp5yK6wLrWvxHneufAiYBAjvereFvfjtDvTbBcZN5ZCsaoMo49s1LKPTwGpowik6QJG"; const toMoonlight = @@ -296,9 +289,6 @@ describe("Wallet store", async () => { expect(currentProfile).toBeDefined(); - fromAccount = /** @type {string} */ (currentProfile?.account.toString()); - fromAddress = /** @type {string} */ (currentProfile?.address.toString()); - treasuryUpdateSpy.mockClear(); balanceSpy.mockClear(); setCachedBalanceSpy.mockClear(); @@ -399,6 +389,48 @@ describe("Wallet store", async () => { await expect(walletStore.setCurrentProfile({})).rejects.toThrow(); }); + it("should expose a method to shield a given amount from the unshielded account", async () => { + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + vi.useRealTimers(); + + const currentlyCachedBalance = await walletCache.getBalanceInfo( + defaultProfile.address.toString() + ); + const newNonce = currentlyCachedBalance.unshielded.nonce + 1n; + + executeSpy.mockResolvedValueOnce({ + hash: phoenixTxResult.hash, + nonce: newNonce, + }); + + const expectedTx = { + amount, + gas, + }; + + await walletStore.shield(amount, gas); + + expect(executeSpy.mock.calls[0][0].attributes).toStrictEqual(expectedTx); + expect(executeSpy.mock.calls[0][0].bookentry.profile).toStrictEqual( + defaultProfile + ); + expect(setPendingNotesSpy).not.toHaveBeenCalled(); + expect(setCachedBalanceSpy).toHaveBeenCalledTimes(1); + expect(setCachedBalanceSpy).toHaveBeenCalledWith( + defaultProfile.address.toString(), + { + ...currentlyCachedBalance, + unshielded: { + ...currentlyCachedBalance.unshielded, + nonce: newNonce, + }, + } + ); + + vi.useFakeTimers(); + }); + it("should expose a method to execute a phoenix transfer, if the receiver is a phoenix address", async () => { /** * For some reason calling `useRealTimers` makes @@ -411,17 +443,17 @@ describe("Wallet store", async () => { const expectedTx = { amount, - from: fromAddress, gas, obfuscated: true, - to: b58.decode(toPhoenix), + to: toPhoenix, }; const result = await walletStore.transfer(toPhoenix, amount, gas); expect(executeSpy).toHaveBeenCalledTimes(1); - - // our TransactionBuilder mock is loaded - expect(executeSpy.mock.calls[0][0].toJSON()).toStrictEqual(expectedTx); + expect(executeSpy.mock.calls[0][0].attributes).toStrictEqual(expectedTx); + expect(executeSpy.mock.calls[0][0].bookentry.profile).toStrictEqual( + defaultProfile + ); expect(setCachedBalanceSpy).not.toHaveBeenCalled(); expect(setPendingNotesSpy).toHaveBeenCalledTimes(1); expect(setPendingNotesSpy).toHaveBeenCalledWith( @@ -502,16 +534,16 @@ describe("Wallet store", async () => { const expectedTx = { amount, - from: fromAccount, gas, - obfuscated: false, - to: b58.decode(toMoonlight), + to: toMoonlight, }; await walletStore.transfer(toMoonlight, amount, gas); - // our TransactionBuilder mock is loaded - expect(executeSpy.mock.calls[0][0].toJSON()).toStrictEqual(expectedTx); + expect(executeSpy.mock.calls[0][0].attributes).toStrictEqual(expectedTx); + expect(executeSpy.mock.calls[0][0].bookentry.profile).toStrictEqual( + defaultProfile + ); expect(setPendingNotesSpy).not.toHaveBeenCalled(); expect(setCachedBalanceSpy).toHaveBeenCalledTimes(1); expect(setCachedBalanceSpy).toHaveBeenCalledWith( @@ -527,5 +559,42 @@ describe("Wallet store", async () => { vi.useFakeTimers(); }); + + it("should expose a method to unshield a given amount from the shielded account", async () => { + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + vi.useRealTimers(); + + const expectedTx = { + amount, + gas, + }; + const result = await walletStore.unshield(amount, gas); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(executeSpy.mock.calls[0][0].attributes).toStrictEqual(expectedTx); + expect(executeSpy.mock.calls[0][0].bookentry.profile).toStrictEqual( + defaultProfile + ); + expect(setCachedBalanceSpy).not.toHaveBeenCalled(); + expect(setPendingNotesSpy).toHaveBeenCalledTimes(1); + expect(setPendingNotesSpy).toHaveBeenCalledWith( + phoenixTxResult.nullifiers, + phoenixTxResult.hash + ); + expect(result).toBe(phoenixTxResult); + + // check that we made a sync before the transfer + expect(treasuryUpdateSpy).toHaveBeenCalledTimes(1); + + // but the balance is not updated yet + expect(balanceSpy).not.toHaveBeenCalled(); + + expect(treasuryUpdateSpy.mock.invocationCallOrder[0]).toBeLessThan( + executeSpy.mock.invocationCallOrder[0] + ); + + vi.useFakeTimers(); + }); }); }); diff --git a/web-wallet/src/lib/stores/stores.d.ts b/web-wallet/src/lib/stores/stores.d.ts index 549d104eb..4bcf1b57e 100644 --- a/web-wallet/src/lib/stores/stores.d.ts +++ b/web-wallet/src/lib/stores/stores.d.ts @@ -111,6 +111,11 @@ type WalletStoreServices = { profile: import("$lib/vendor/w3sper.js/src/mod").Profile ) => Promise; + shield: ( + amount: bigint, + gas: import("$lib/vendor/w3sper.js/src/mod").Gas + ) => Promise; + stake: ( amount: number, gas: import("$lib/vendor/w3sper.js/src/mod").Gas @@ -124,6 +129,11 @@ type WalletStoreServices = { gas: import("$lib/vendor/w3sper.js/src/mod").Gas ) => Promise; + unshield: ( + amount: bigint, + gas: import("$lib/vendor/w3sper.js/src/mod").Gas + ) => Promise; + unstake: (gas: import("$lib/vendor/w3sper.js/src/mod").Gas) => Promise; withdrawReward: ( diff --git a/web-wallet/src/lib/stores/walletStore.js b/web-wallet/src/lib/stores/walletStore.js index 181d622cd..26e649294 100644 --- a/web-wallet/src/lib/stores/walletStore.js +++ b/web-wallet/src/lib/stores/walletStore.js @@ -5,7 +5,6 @@ import { Bookmark, ProfileGenerator, } from "$lib/vendor/w3sper.js/src/mod"; -import * as b58 from "$lib/vendor/w3sper.js/src/b58"; import walletCache from "$lib/wallet-cache"; import WalletTreasury from "$lib/wallet-treasury"; @@ -55,8 +54,7 @@ const { set, subscribe, update } = walletStore; const treasury = new WalletTreasury(); const bookkeeper = new Bookkeeper(treasury); -const getCurrentAccount = () => get(walletStore).currentProfile?.account; -const getCurrentAddress = () => get(walletStore).currentProfile?.address; +const getCurrentProfile = () => get(walletStore).currentProfile; /** @type {(...args: any) => Promise} */ const asyncNoopFailure = () => Promise.reject(new Error("Not implemented")); @@ -79,6 +77,36 @@ const passThruWithEffects = (fn) => (a) => { return a; }; +/** @type {(txInfo: TransactionInfo) => Promise} */ +const updateCacheAfterTransaction = async (txInfo) => { + // we did a phoenix transaction + if ("nullifiers" in txInfo) { + /** + * For now we ignore the possible error while + * writing the pending notes info, as we'll + * change soon how they are handled (probably by w3sper directly). + */ + await walletCache + .setPendingNoteInfo(txInfo.nullifiers, txInfo.hash) + .catch(() => {}); + } else { + const address = String(getCurrentProfile()?.address); + const currentBalance = await walletCache.getBalanceInfo(address); + + /** + * We update the stored `nonce` so that if a transaction is made + * before the sync gives us an updated one, the transaction + * won't be rejected by reusing the old value. + */ + await walletCache.setBalanceInfo( + address, + setPathIn(currentBalance, "unshielded.nonce", txInfo.nonce) + ); + } + + return txInfo; +}; + /** @type {() => Promise} */ const updateBalance = async () => { const { currentProfile } = get(walletStore); @@ -279,51 +307,47 @@ async function sync(fromBlock) { return syncPromise; } +/** @type {WalletStoreServices["shield"]} */ +const shield = async (amount, gas) => + sync() + .then(networkStore.connect) + .then((network) => + network.execute( + bookkeeper.as(getCurrentProfile()).shield(amount).gas(gas) + ) + ) + .then(updateCacheAfterTransaction) + .then(passThruWithEffects(observeTxRemoval)); + /** @type {WalletStoreServices["transfer"]} */ const transfer = async (to, amount, gas) => sync() .then(networkStore.connect) .then((network) => { - const tx = bookkeeper.transfer(amount).to(b58.decode(to)).gas(gas); + const tx = bookkeeper + .as(getCurrentProfile()) + .transfer(amount) + .to(to) + .gas(gas); return network.execute( - ProfileGenerator.typeOf(to) === "address" - ? tx.from(getCurrentAddress()).obfuscated() - : tx.from(getCurrentAccount()) + // @ts-ignore we don't have access to the AddressTransfer type + ProfileGenerator.typeOf(to) === "address" ? tx.obfuscated() : tx ); }) - .then( - /** @type {(txInfo: TransactionInfo) => Promise} */ async ( - txInfo - ) => { - // we did a phoenix transaction - if ("nullifiers" in txInfo) { - /** - * For now we ignore the possible error while - * writing the pending notes info, as we'll - * change soon how they are handled (probably by w3sper directly). - */ - await walletCache - .setPendingNoteInfo(txInfo.nullifiers, txInfo.hash) - .catch(() => {}); - } else { - const address = String(getCurrentAddress()); - const currentBalance = await walletCache.getBalanceInfo(address); - - /** - * We update the stored `nonce` so that if a transaction is made - * before the sync gives us an updated one, the transaction - * won't be rejected by reusing the old value. - */ - await walletCache.setBalanceInfo( - address, - setPathIn(currentBalance, "unshielded.nonce", txInfo.nonce) - ); - } + .then(updateCacheAfterTransaction) + .then(passThruWithEffects(observeTxRemoval)); - return txInfo; - } +/** @type {WalletStoreServices["unshield"]} */ +const unshield = async (amount, gas) => + sync() + .then(networkStore.connect) + .then((network) => + network.execute( + bookkeeper.as(getCurrentProfile()).unshield(amount).gas(gas) + ) ) + .then(updateCacheAfterTransaction) .then(passThruWithEffects(observeTxRemoval)); /** @type {WalletStoreServices["unstake"]} */ @@ -342,10 +366,12 @@ export default { init, reset, setCurrentProfile, + shield, stake, subscribe, sync, transfer, + unshield, unstake, withdrawReward, }; diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/bookkeeper.js b/web-wallet/src/lib/vendor/w3sper.js/src/bookkeeper.js index 8e1bb7cfa..648a4c1a3 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/bookkeeper.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/bookkeeper.js @@ -6,9 +6,38 @@ // Copyright (c) DUSK NETWORK. All rights reserved. import * as ProtocolDriver from "../src/protocol-driver/mod.js"; -import { ProfileGenerator } from "./profile.js"; +import { ProfileGenerator, Profile } from "./profile.js"; -import { TransactionBuilder } from "../src/transaction.js"; +import { + Transfer, + UnshieldTransfer, + ShieldTransfer, +} from "../src/transaction.js"; + +class BookEntry { + constructor(bookkeeper, profile) { + this.bookkeeper = bookkeeper; + this.profile = profile; + + Object.freeze(this); + } + + async balance(type) { + return this.bookkeeper.balance(this.profile[type]); + } + + transfer(amount) { + return new Transfer(this).amount(amount); + } + + unshield(amount) { + return new UnshieldTransfer(this).amount(amount); + } + + shield(amount) { + return new ShieldTransfer(this).amount(amount); + } +} export class Bookkeeper { #treasury; @@ -18,8 +47,7 @@ export class Bookkeeper { } async balance(identifier) { - const type = ProfileGenerator.typeOf(identifier.toString()); - + const type = ProfileGenerator.typeOf(String(identifier)); switch (type) { case "account": return await this.#treasury.account(identifier); @@ -46,7 +74,11 @@ export class Bookkeeper { return ProtocolDriver.pickNotes(identifier, notes, amount); } - transfer(amount) { - return new TransactionBuilder(this).amount(amount); + as(profile) { + if (!(profile instanceof Profile)) { + throw new TypeError(`${profile} is not a Profile instance`); + } + + return new BookEntry(this, profile); } } diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/mod.js b/web-wallet/src/lib/vendor/w3sper.js/src/mod.js index 973cfe38f..1697be4fc 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/mod.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/mod.js @@ -8,3 +8,4 @@ export * from "./network/mod.js"; export * from "./profile.js"; export * from "./bookkeeper.js"; +export * from "./transaction.js"; diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/network/gas.js b/web-wallet/src/lib/vendor/w3sper.js/src/network/gas.js index 04384a636..6150915d3 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/network/gas.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/network/gas.js @@ -33,8 +33,8 @@ export class Gas { // Passing null/undefined/0 or negative values will set the default value for price and limit constructor({ limit, price } = {}) { - this.limit = max(limit, 0n) || Gas.DEFAULT_LIMIT; - this.price = max(price, 0n) || Gas.DEFAULT_PRICE; + this.limit = max(BigInt(limit || 0), 0n) || Gas.DEFAULT_LIMIT; + this.price = max(BigInt(price || 0), 0n) || Gas.DEFAULT_PRICE; this.total = this.limit * this.price; Object.freeze(this); diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/network/mod.js b/web-wallet/src/lib/vendor/w3sper.js/src/network/mod.js index dc595187a..38b15a892 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/network/mod.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/network/mod.js @@ -89,8 +89,10 @@ export class Network { ); } - async execute(builder) { - const tx = await builder.build(this); + async execute(tx) { + if (typeof tx?.build === "function") { + tx = await tx.build(this); + } // Attempt to preverify the transaction await this.transactions.preverify(tx.buffer); diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/protocol-driver/mod.js b/web-wallet/src/lib/vendor/w3sper.js/src/protocol-driver/mod.js index e6e20c929..6bd242209 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/protocol-driver/mod.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/protocol-driver/mod.js @@ -385,9 +385,9 @@ export const accountsIntoRaw = async (users) => return result; })(); -export const intoProved = async (tx, proof) => +export const intoProven = async (tx, proof) => protocolDriverModule.task(async function ( - { malloc, into_proved }, + { malloc, into_proven }, { memcpy } ) { let buffer = tx.valueOf(); @@ -406,7 +406,7 @@ export const intoProved = async (tx, proof) => let proved_ptr = await malloc(4); let hash_ptr = await malloc(64); - const code = await into_proved(tx_ptr, proof_ptr, proved_ptr, hash_ptr); + const code = await into_proven(tx_ptr, proof_ptr, proved_ptr, hash_ptr); if (code > 0) throw DriverError.from(code); proved_ptr = new DataView( @@ -437,6 +437,7 @@ export const phoenix = async (info) => const sender_index = +info.sender; const receiver = info.receiver.valueOf(); + ptr.receiver = await malloc(receiver.byteLength); await memcpy(ptr.receiver, receiver); @@ -606,3 +607,179 @@ export const moonlight = async (info) => hash = new TextDecoder().decode(await memcpy(null, hash, 64)); return [tx_buffer, hash]; })(); + +export const unshield = async (info) => + protocolDriverModule.task(async function ( + { malloc, phoenix_to_moonlight }, + { memcpy } + ) { + const ptr = Object.create(null); + + const seed = new Uint8Array(await info.profile.seed); + + ptr.seed = await malloc(64); + await memcpy(ptr.seed, seed, 64); + + ptr.rng = await malloc(32); + await memcpy(ptr.rng, new Uint8Array(rng())); + + const profile_index = +info.profile; + + const inputs = DataBuffer.from(info.inputs); + + ptr.inputs = await malloc(inputs.byteLength); + await memcpy(ptr.inputs, new Uint8Array(inputs)); + + const openings = DataBuffer.from(info.openings); + ptr.openings = await malloc(openings.byteLength); + await memcpy(ptr.openings, new Uint8Array(openings)); + + const nullifiers = DataBuffer.from(info.nullifiers); + ptr.nullifiers = await malloc(nullifiers.byteLength); + await memcpy(ptr.nullifiers, new Uint8Array(nullifiers)); + + const root = info.root; + ptr.root = await malloc(root.byteLength); + await memcpy(ptr.root, new Uint8Array(root)); + + const allocate_value = new Uint8Array(8); + new DataView(allocate_value.buffer).setBigUint64( + 0, + info.allocate_value, + true + ); + ptr.allocate_value = await malloc(8); + await memcpy(ptr.allocate_value, allocate_value); + + const gas_limit = new Uint8Array(8); + new DataView(gas_limit.buffer).setBigUint64(0, info.gas_limit, true); + ptr.gas_limit = await malloc(8); + await memcpy(ptr.gas_limit, gas_limit); + + const gas_price = new Uint8Array(8); + new DataView(gas_price.buffer).setBigUint64(0, info.gas_price, true); + ptr.gas_price = await malloc(8); + await memcpy(ptr.gas_price, gas_price); + + let tx = await malloc(4); + let proof = await malloc(4); + + // Copy the value to the WASM memory + const code = await phoenix_to_moonlight( + ptr.rng, + ptr.seed, + profile_index, + ptr.inputs, + ptr.openings, + ptr.nullifiers, + ptr.root, + ptr.allocate_value, + ptr.gas_limit, + ptr.gas_price, + info.chainId, + tx, + proof + ); + + if (code > 0) throw DriverError.from(code); + + let tx_ptr = new DataView((await memcpy(null, tx, 4)).buffer).getUint32( + 0, + true + ); + + let tx_len = new DataView((await memcpy(null, tx_ptr, 4)).buffer).getUint32( + 0, + true + ); + + const tx_buffer = await memcpy(null, tx_ptr, tx_len); + + let proof_ptr = new DataView( + (await memcpy(null, proof, 4)).buffer + ).getUint32(0, true); + + let proof_len = new DataView( + (await memcpy(null, proof_ptr, 4)).buffer + ).getUint32(0, true); + + const proof_buffer = await memcpy(null, proof_ptr + 4, proof_len); + + return [tx_buffer, proof_buffer]; + })(); + +export const shield = async (info) => + protocolDriverModule.task(async function ( + { malloc, moonlight_to_phoenix }, + { memcpy } + ) { + const ptr = Object.create(null); + + const seed = new Uint8Array(await info.profile.seed); + + ptr.seed = await malloc(64); + await memcpy(ptr.seed, seed, 64); + + const profile_index = +info.profile; + + ptr.rng = await malloc(32); + await memcpy(ptr.rng, new Uint8Array(rng())); + + const allocate_value = new Uint8Array(8); + new DataView(allocate_value.buffer).setBigUint64( + 0, + info.allocate_value, + true + ); + ptr.allocate_value = await malloc(8); + await memcpy(ptr.allocate_value, allocate_value); + + const gas_limit = new Uint8Array(8); + new DataView(gas_limit.buffer).setBigUint64(0, info.gas_limit, true); + ptr.gas_limit = await malloc(8); + await memcpy(ptr.gas_limit, gas_limit); + + const gas_price = new Uint8Array(8); + new DataView(gas_price.buffer).setBigUint64(0, info.gas_price, true); + ptr.gas_price = await malloc(8); + await memcpy(ptr.gas_price, gas_price); + + const nonce = new Uint8Array(8); + new DataView(nonce.buffer).setBigUint64(0, info.nonce, true); + ptr.nonce = await malloc(8); + await memcpy(ptr.nonce, nonce); + + let tx = await malloc(4); + let hash = await malloc(64); + + // Copy the value to the WASM memory + const code = await moonlight_to_phoenix( + ptr.rng, + ptr.seed, + profile_index, + ptr.allocate_value, + ptr.gas_limit, + ptr.gas_price, + ptr.nonce, + info.chainId, + tx, + hash + ); + + if (code > 0) throw DriverError.from(code); + + let tx_ptr = new DataView((await memcpy(null, tx, 4)).buffer).getUint32( + 0, + true + ); + + let tx_len = new DataView((await memcpy(null, tx_ptr, 4)).buffer).getUint32( + 0, + true + ); + + const tx_buffer = await memcpy(null, tx_ptr + 4, tx_len); + + hash = new TextDecoder().decode(await memcpy(null, hash, 64)); + return [tx_buffer, hash]; + })(); diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/transaction.js b/web-wallet/src/lib/vendor/w3sper.js/src/transaction.js index b690bf34a..ea8ab956b 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/transaction.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/transaction.js @@ -11,53 +11,155 @@ export const TRANSFER = import { AddressSyncer } from "./network/syncer/address.js"; import { Gas } from "./network/gas.js"; import * as ProtocolDriver from "./protocol-driver/mod.js"; -import { ProfileGenerator } from "./profile.js"; +import { ProfileGenerator, Profile } from "./profile.js"; +import * as base58 from "./b58.js"; -export class TransactionBuilder { - #bookkeeper; +const _attributes = Symbol("builder::attributes"); - #from; - #to; - #amount; - #obfuscated = false; - #gas; +class BasicTransfer { + [_attributes]; - constructor(bookkeeper) { - this.#bookkeeper = bookkeeper; + constructor(from) { + this[_attributes] = Object.create(null); - this.#gas = new Gas(); + const value = from instanceof Profile ? { profile: from } : from; + + Object.defineProperty(this, "bookentry", { + value, + }); + + this[_attributes].gas = new Gas(); + } + + get attributes() { + return { ...this[_attributes] }; } - from(identifier) { - this.#from = identifier; + amount(value) { + this[_attributes].amount = value; return this; } - to(identifier) { - this.#to = identifier; + gas(value) { + this[_attributes].gas = new Gas(value); return this; } +} - amount(value) { - this.#amount = value; +export class Transfer extends BasicTransfer { + constructor(from) { + super(from); + } + + to(value) { + let builder; + let identifier = String(value); + switch (ProfileGenerator.typeOf(identifier)) { + case "account": + builder = new AccountTransfer(this.bookentry); + break; + case "address": + builder = new AddressTransfer(this.bookentry); + break; + default: + throw new TypeError("Invalid identifier"); + } + this[_attributes].to = identifier; + builder[_attributes] = this.attributes; + + return builder; + } +} + +class AccountTransfer extends Transfer { + constructor(from) { + super(from); + } + + chain(value) { + this[_attributes].chain = value; return this; } - obfuscated() { - this.#obfuscated = true; + nonce(value) { + this[_attributes].nonce = value; return this; } - gas(value) { - this.#gas = new Gas(value); + async build(network) { + const sender = this.bookentry.profile; + const { attributes } = this; + const { to, amount: transfer_value, gas } = attributes; + + const receiver = base58.decode(to); + + // Obtain the chain id + let chainId; + if (!isNaN(+attributes.chain)) { + chainId = +attributes.chain; + } else if (network) { + ({ chainId } = await network.node.info); + } else { + throw new Error("Chain ID is required."); + } + + // Obtain the nonce + let nonce; + if ("nonce" in attributes) { + ({ nonce } = attributes); + } else if (typeof this.bookentry?.balance === "function") { + ({ nonce } = await this.bookentry.balance("account")); + } + + nonce += 1n; + + let [buffer, hash] = await ProtocolDriver.moonlight({ + sender, + receiver, + transfer_value, + deposit: 0n, + gas_limit: gas.limit, + gas_price: gas.price, + nonce, + chainId, + data: null, + }); + + return Object.freeze({ + buffer, + hash, + nonce, + }); + } +} + +class AddressTransfer extends Transfer { + constructor(from) { + super(from); + } + + obfuscated() { + this[_attributes].obfuscated = true; return this; } - async #addressBuild(network) { + async build(network) { + const { attributes } = this; + const { + to, + amount: transfer_value, + obfuscated: obfuscated_transaction, + gas, + } = attributes; + const sender = this.bookentry.profile; + const receiver = base58.decode(to); + + const { bookkeeper } = this.bookentry; + // Pick notes to spend from the treasury - const picked = await this.#bookkeeper.pick( - this.#from, - this.#amount + this.#gas.total + const picked = await bookkeeper.pick( + sender.address, + transfer_value + gas.total ); const syncer = new AddressSyncer(network); @@ -70,8 +172,6 @@ export class TransactionBuilder { // Fetch the root const root = await syncer.root; - const sender = this.#from; - const receiver = this.#to; const inputs = picked.values(); const nullifiers = [...picked.keys()]; @@ -85,11 +185,11 @@ export class TransactionBuilder { inputs, openings, root, - transfer_value: this.#amount, - obfuscated_transaction: this.#obfuscated, + transfer_value, + obfuscated_transaction, deposit: 0n, - gas_limit: this.#gas.limit, - gas_price: this.#gas.price, + gas_limit: gas.limit, + gas_price: gas.price, chainId, data: null, }); @@ -97,8 +197,8 @@ export class TransactionBuilder { // Attempt to prove the transaction const proof = await network.prove(circuits); - // Transform the unproven transaction into a proved transaction - const [buffer, hash] = await ProtocolDriver.intoProved(tx, proof); + // Transform the unproven transaction into a proven transaction + const [buffer, hash] = await ProtocolDriver.intoProven(tx, proof); return Object.freeze({ buffer, @@ -106,28 +206,92 @@ export class TransactionBuilder { nullifiers, }); } +} - async #accountBuild(network) { - const sender = this.#from; - const receiver = this.#to; +export class UnshieldTransfer extends BasicTransfer { + constructor(from) { + super(from); + } + + async build(network) { + const { attributes } = this; + const { amount: allocate_value, gas } = attributes; + const { profile, bookkeeper } = this.bookentry; + + // Pick notes to spend from the treasury + const picked = await bookkeeper.pick( + profile.address, + allocate_value + gas.total + ); + + const syncer = new AddressSyncer(network); + + // Fetch the openings from the network for the picked notes + const openings = (await syncer.openings(picked)).map((opening) => { + return new Uint8Array(opening.slice(0)); + }); + + // Fetch the root + const root = await syncer.root; + + const inputs = picked.values(); + const nullifiers = [...picked.keys()]; + + // Get the chain id from the network + const { chainId } = await network.node.info; + + // Create the unproven transaction + let [tx, circuits] = await ProtocolDriver.unshield({ + profile, + inputs, + openings, + nullifiers, + root, + allocate_value, + gas_limit: gas.limit, + gas_price: gas.price, + chainId, + }); + + // Attempt to prove the transaction + const proof = await network.prove(circuits); + + // Transform the unproven transaction into a proven transaction + const [buffer, hash] = await ProtocolDriver.intoProven(tx, proof); + + return Object.freeze({ + buffer, + hash, + nullifiers, + }); + } +} + +export class ShieldTransfer extends BasicTransfer { + constructor(from) { + super(from); + } + + async build(network) { + const { attributes } = this; + const { amount: allocate_value, gas } = attributes; + const { profile, bookkeeper } = this.bookentry; // Get the chain id from the network const { chainId } = await network.node.info; - // Get the nonce - let { nonce } = await this.#bookkeeper.balance(sender); + // Obtain the nonce + let { nonce } = await this.bookentry.balance("account"); + nonce += 1n; - let [buffer, hash] = await ProtocolDriver.moonlight({ - sender, - receiver, - transfer_value: this.#amount, - deposit: 0n, - gas_limit: this.#gas.limit, - gas_price: this.#gas.price, + let [buffer, hash] = await ProtocolDriver.shield({ + profile, + allocate_value, + gas_limit: gas.limit, + gas_price: gas.price, nonce, chainId, - data: null, }); return Object.freeze({ @@ -136,13 +300,4 @@ export class TransactionBuilder { nonce, }); } - - async build(network) { - switch (ProfileGenerator.typeOf(this.#from.toString())) { - case "account": - return this.#accountBuild(network); - case "address": - return this.#addressBuild(network); - } - } } diff --git a/web-wallet/vite-setup.js b/web-wallet/vite-setup.js index d17cd1a85..156d25f82 100644 --- a/web-wallet/vite-setup.js +++ b/web-wallet/vite-setup.js @@ -51,15 +51,6 @@ Object.defineProperty(window, "litIssuedWarnings", { writable: false, }); -vi.mock( - "./src/lib/vendor/w3sper.js/src/transaction", - async (importOriginal) => ({ - ...(await importOriginal()), - TransactionBuilder: (await import("./src/__mocks__/TransactionBuilder.js")) - .default, - }) -); - /* * Add a polyfill for Promise.withResolvers for Node 20 */