diff --git a/__tests__/integration/hathorwallet_prepare_without_sign.test.ts b/__tests__/integration/hathorwallet_prepare_without_sign.test.ts new file mode 100644 index 000000000..d0b4c2706 --- /dev/null +++ b/__tests__/integration/hathorwallet_prepare_without_sign.test.ts @@ -0,0 +1,261 @@ +import { isEmpty } from 'lodash'; +import { GenesisWalletHelper } from './helpers/genesis-wallet.helper'; +import { + DEFAULT_PIN_CODE, + generateWalletHelper, + stopAllWallets, + waitForTxReceived, + waitTxConfirmed, +} from './helpers/wallet.helper'; + +import ncApi from '../../src/api/nano'; +import HathorWallet from '../../src/new/wallet'; +import { NATIVE_TOKEN_UID, NANO_CONTRACTS_INITIALIZE_METHOD } from '../../src/constants'; +import { TokenVersion } from '../../src/types'; +import { SendTransaction } from '../../src'; + +describe('HathorWallet prepare transaction without signing', () => { + let hWallet: HathorWallet; + let contractId: string; + let fbtUid: string; + + beforeAll(async () => { + hWallet = await generateWalletHelper(null); + const address = await hWallet.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(hWallet, address, 10000n, {}); + + // Initialize a FeeBlueprint contract + const initTx = await hWallet.createAndSendNanoContractTransaction( + NANO_CONTRACTS_INITIALIZE_METHOD, + address, + { + blueprintId: global.FEE_BLUEPRINT_ID, + args: [], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: 1000n, + changeAddress: address, + }, + ], + } + ); + await checkTxValid(hWallet, initTx); + contractId = initTx.hash!; + + // Create a fee token (FBT) + const createFbtTx = await hWallet.createAndSendNanoContractTransaction( + 'create_fee_token', + address, + { + ncId: contractId, + args: ['Fee Test Token', 'FBT', 1000], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: 100n, + changeAddress: address, + }, + ], + } + ); + await checkTxValid(hWallet, createFbtTx); + + const ncState = await ncApi.getNanoContractState( + contractId, + ['fbt_uid'], + [NATIVE_TOKEN_UID], + [] + ); + fbtUid = ncState.fields.fbt_uid.value; + }); + + afterAll(async () => { + await hWallet.stop(); + await stopAllWallets(); + await GenesisWalletHelper.clearListeners(); + }); + + const checkTxValid = async (wallet, tx) => { + const txId = tx.hash; + expect(txId).toBeDefined(); + await waitForTxReceived(wallet, txId); + await waitTxConfirmed(wallet, txId, null); + const txAfterExecution = await wallet.getFullTxById(txId); + expect(isEmpty(txAfterExecution.meta.voided_by)).toBe(true); + expect(txAfterExecution.meta.first_block).not.toBeNull(); + }; + + it('should build tx without signing, edit caller, sign, and send', async () => { + const address0 = await hWallet.getAddressAtIndex(0); + const address1 = await hWallet.getAddressAtIndex(1); + + // First, withdraw some FBT from the contract to have tokens to deposit + const withdrawTx = await hWallet.createAndSendNanoContractTransaction('noop', address0, { + ncId: contractId, + args: [], + actions: [ + { + type: 'withdrawal', + token: fbtUid, + amount: 10n, + address: address0, + }, + ], + }); + await checkTxValid(hWallet, withdrawTx); + + const fbtDepositAmount = 5n; + const expectedFee = 2n; + + // 1. Build unsigned transaction with address0 as caller + const sendTransaction = await hWallet.createNanoContractTransaction( + 'noop', + address0, + { + ncId: contractId, + args: [], + actions: [ + { + type: 'deposit', + token: fbtUid, + amount: fbtDepositAmount, + changeAddress: address0, + }, + ], + }, + { signTx: false } + ); + + const txBeforeChangeCaller = sendTransaction.transaction; + + // 2. Assert tx is built but NOT signed + // Inputs: FBT (for deposit) + HTR (for fee) + expect(txBeforeChangeCaller.inputs.length).toBeGreaterThan(0); + for (const input of txBeforeChangeCaller.inputs) { + expect(input.data).toBeNull(); + } + + const nanoHeadersBeforeChangeCaller = txBeforeChangeCaller.getNanoHeaders(); + expect(nanoHeadersBeforeChangeCaller).toHaveLength(1); + expect(nanoHeadersBeforeChangeCaller[0].script).toBeNull(); + expect(nanoHeadersBeforeChangeCaller[0].address.base58).toBe(address0); + + // Outputs: FBT change + HTR change + expect(txBeforeChangeCaller.outputs).toHaveLength(2); + expect(txBeforeChangeCaller.outputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tokenData: 1, + }), + expect.objectContaining({ + tokenData: 0, + }), + ]) + ); + + // 3. Edit caller: change address AND seqnum for the new caller + await hWallet.setNanoHeaderCaller(nanoHeadersBeforeChangeCaller![0], address1); + await hWallet.signTx(txBeforeChangeCaller); + + // 4. Sign the transaction (signs both inputs AND nano header with new caller) + const txAfterSign = await hWallet.signTx(txBeforeChangeCaller); + const nanoHeadersAfterSign = txAfterSign.getNanoHeaders(); + + // 5. Assert tx IS now signed + for (const input of txAfterSign.inputs) { + expect(input.data).not.toBeNull(); + } + expect(nanoHeadersAfterSign[0].script).not.toBeNull(); + // Verify the caller was changed + expect(nanoHeadersAfterSign[0].address.base58).toBe(address1); + + // 6. Verify FeeHeader + const feeHeader = txAfterSign.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + expect(feeHeader!.entries[0].amount).toBe(expectedFee); + + // 7. Send and verify not voided + const result = await sendTransaction.runFromMining(); + await checkTxValid(hWallet, result); + }); + + it('should build token creation tx without signing, edit caller, sign, and send', async () => { + const address0 = await hWallet.getAddressAtIndex(0); + const address1 = await hWallet.getAddressAtIndex(1); + + // Inject more funds since previous test consumed some + await GenesisWalletHelper.injectFunds(hWallet, address0, 1000n, {}); + + // 1. Build unsigned token creation transaction with address0 as caller + const sendTransaction: SendTransaction = await hWallet.createNanoContractCreateTokenTransaction( + 'noop', + address0, + { + ncId: contractId, + args: [], + actions: [], + }, + { + name: 'Test Token Unsigned', + symbol: 'TTU', + amount: 500n, + mintAddress: address0, + tokenVersion: TokenVersion.FEE, + }, + { signTx: false } + ); + const txBeforeChangeCaller = sendTransaction.transaction; + + // 2. Assert tx is built but NOT signed + // Inputs should exist (for HTR deposit) + expect(txBeforeChangeCaller?.inputs.length).toBeGreaterThan(0); + for (const input of txBeforeChangeCaller?.inputs || []) { + expect(input.data).toBeNull(); + } + + const nanoHeadersBeforeChangeCaller = txBeforeChangeCaller?.getNanoHeaders(); + expect(nanoHeadersBeforeChangeCaller).toHaveLength(1); + expect(nanoHeadersBeforeChangeCaller![0].script).toBeNull(); + expect(nanoHeadersBeforeChangeCaller![0].address.base58).toBe(address0); + + // Outputs should include token mint outputs and HTR change + expect(txBeforeChangeCaller?.outputs.length).toBeGreaterThan(0); + expect(txBeforeChangeCaller?.outputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tokenData: 0, // HTR change + }), + ]) + ); + + // 3. Edit caller: change address AND seqnum for the new caller + await hWallet.setNanoHeaderCaller(nanoHeadersBeforeChangeCaller![0], address1); + + // 4. Sign the transaction (signs both inputs AND nano header with new caller) + const tx = await hWallet.signTx(txBeforeChangeCaller!, { pinCode: DEFAULT_PIN_CODE }); + const nanoHeadersAfterSign = tx.getNanoHeaders(); + + // 5. Assert tx IS now signed + for (const input of tx.inputs) { + expect(input.data).not.toBeNull(); + } + expect(nanoHeadersAfterSign![0].script).not.toBeNull(); + // Verify the caller was changed + expect(nanoHeadersAfterSign![0].address.base58).toBe(address1); + + // 6. Verify FeeHeader exists for token creation + const feeHeader = tx.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + + // 7. Send and verify not voided + const result = await sendTransaction.runFromMining(); + await checkTxValid(hWallet, result); + + // 8. Verify token was created + const newTokenUid = result.hash; + expect(newTokenUid).toBeDefined(); + }); +}); diff --git a/__tests__/integration/helpers/service-facade.helper.ts b/__tests__/integration/helpers/service-facade.helper.ts index 2efeb3cb8..aa9ff6b72 100644 --- a/__tests__/integration/helpers/service-facade.helper.ts +++ b/__tests__/integration/helpers/service-facade.helper.ts @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import { loggers } from '../utils/logger.util'; import { delay } from '../utils/core.util'; import { HathorWalletServiceWallet, MemoryStore, Storage, walletUtils } from '../../../src'; @@ -6,6 +7,7 @@ import { FULLNODE_URL, NETWORK_NAME } from '../configuration/test-constants'; import { TxNotFoundError } from '../../../src/errors'; import { precalculationHelpers } from './wallet-precalculation.helper'; import config from '../../../src/config'; +import ncApi from '../../../src/api/nano'; /** Default pin to simplify the tests */ const pinCode = '123456'; @@ -130,3 +132,77 @@ export async function generateNewWalletAddress() { addresses, }; } + +/** + * Poll for nano contract state with retries. + * The fullnode may not have indexed the contract immediately after wallet-service confirms the tx. + * @param ncId - Nano contract ID + * @param fields - Fields to retrieve + * @param requiredField - Optional field that must have a non-null value + * @param maxAttempts - Maximum polling attempts + * @param delayMs - Delay between attempts + */ +export async function pollForNcState( + ncId: string, + fields: string[], + requiredField?: string, + maxAttempts = 10, + delayMs = 1000 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const state = await ncApi.getNanoContractState(ncId, fields, [], []); + // If a required field is specified, check that it has a value + if (requiredField) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fieldValue = (state.fields as any)[requiredField]?.value; + if (fieldValue == null) { + if (attempt === maxAttempts - 1) { + throw new Error(`Required field ${requiredField} not found in contract state`); + } + await delay(delayMs); + continue; + } + } + return state; + } catch (error) { + if (attempt === maxAttempts - 1) throw error; + await delay(delayMs); + } + } + throw new Error(`Failed to get nano contract state after ${maxAttempts} attempts`); +} + +/** + * Poll for token details with retries. + * The wallet-service may not have indexed the token immediately after creation. + */ +export async function pollForTokenDetails( + wallet: HathorWalletServiceWallet, + tokenId: string, + maxAttempts = 20, + delayMs = 2000 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await wallet.getTokenDetails(tokenId); + return; + } catch (error) { + if (attempt === maxAttempts - 1) throw error; + await delay(delayMs); + } + } +} + +/** + * Check that a transaction is valid (not voided). + * Uses the wallet-service proxy API via getFullTxById. + */ +export async function checkTxNotVoided( + wallet: HathorWalletServiceWallet, + txId: string +): Promise { + const txData = await wallet.getFullTxById(txId); + expect(txData.success).toBe(true); + expect(isEmpty(txData.meta.voided_by)).toBe(true); +} diff --git a/__tests__/integration/template/transaction/fee.test.ts b/__tests__/integration/template/transaction/fee.test.ts index 956232631..8c7a6355d 100644 --- a/__tests__/integration/template/transaction/fee.test.ts +++ b/__tests__/integration/template/transaction/fee.test.ts @@ -167,6 +167,69 @@ describe('FeeBlueprint Template execution', () => { ) ).rejects.toThrow(/exceeds maximum fee/); }); + it('should create a fee token and deposit htr into the contract', async () => { + const address0 = await hWallet.getAddressAtIndex(0); + + const htrBalanceBefore = await hWallet.getBalance(NATIVE_TOKEN_UID); + + const tx = await hWallet.createAndSendNanoContractCreateTokenTransaction( + 'noop', + address0, + { + ncId: contractId, + args: [], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: 10n, + changeAddress: address0, + }, + ], + }, + { + name: 'FeeTokenWithDeposit', + symbol: 'FBTWD', + amount: 1000n, + mintAddress: address0, + tokenVersion: TokenVersion.FEE, + } + ); + await checkTxValid(hWallet, tx); + + // Verify the deposit output has the REDUCED amount (same as deposit tokens) + // deposit(10n) - fee(1n) = output(9n) + const createTokenTx = tx as CreateTokenTransaction; + + // token output + expect(createTokenTx.outputs.length).toBe(4); + expect(createTokenTx.outputs[0].value).toBe(1000n); + // authorities outputs + expect(createTokenTx.outputs[1].value).toBe(1n); + expect(createTokenTx.outputs[1].tokenData).toBe(129); + expect(createTokenTx.outputs[2].value).toBe(2n); + expect(createTokenTx.outputs[2].tokenData).toBe(129); + + // deposit + fee = 10n + 1n = 11n + const expectedHtrBalance = htrBalanceBefore[0].balance.unlocked - 10n - 1n; + // change output in native token + expect(createTokenTx.outputs[3].value).toBe(expectedHtrBalance); + expect(createTokenTx.outputs[3].tokenData).toBe(0); + + // Verify FeeHeader exists and has correct fee + const feeHeader = tx.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + expect(feeHeader!.entries[0].amount).toBe(1n); + + // Verify token was created with FEE version + const tokenDetails = await hWallet.getTokenDetails(tx.hash!); + expect(tokenDetails.tokenInfo.version).toBe(TokenVersion.FEE); + + const nanoHeader = createTokenTx.getNanoHeaders(); + expect(nanoHeader.length).toBe(1); + expect(nanoHeader[0].actions.length).toBe(1); + expect(nanoHeader[0].actions[0].type).toBe(NanoContractHeaderActionType.DEPOSIT); + }); it('should create fee token with withdrawal and contract pays fees', async () => { const address0 = await hWallet.getAddressAtIndex(0); diff --git a/__tests__/integration/walletservice_nano_fee.test.ts b/__tests__/integration/walletservice_nano_fee.test.ts new file mode 100644 index 000000000..c083fabaa --- /dev/null +++ b/__tests__/integration/walletservice_nano_fee.test.ts @@ -0,0 +1,277 @@ +import { GenesisWalletServiceHelper } from './helpers/genesis-wallet.helper'; +import { + buildWalletInstance, + checkTxNotVoided, + initializeServiceGlobalConfigs, + pollForNcState, + pollForTokenDetails, + pollForTx, +} from './helpers/service-facade.helper'; +import HathorWalletServiceWallet from '../../src/wallet/wallet'; +import { NATIVE_TOKEN_UID, NANO_CONTRACTS_INITIALIZE_METHOD } from '../../src/constants'; +import { TokenVersion } from '../../src/types'; + +const pinCode = '123456'; +const password = 'testpass'; + +initializeServiceGlobalConfigs(); + +describe('WalletService Nano Contract Fee Tests', () => { + let wsWallet: HathorWalletServiceWallet; + let walletAddresses: string[]; + let contractId: string; + let fbtUid: string; + + beforeAll(async () => { + // 1. Start genesis wallet helper + await GenesisWalletServiceHelper.start(); + const gWallet = GenesisWalletServiceHelper.getSingleton(); + + // 2. Build and start wsWallet (uses precalculated wallet) + const buildResult = buildWalletInstance({}); + wsWallet = buildResult.wallet; + walletAddresses = buildResult.addresses; + await wsWallet.start({ pinCode, password }); + + // 3. Fund wallet with HTR + const address0 = walletAddresses[0]; + const fundTx = await gWallet.sendTransaction(address0, 1000n, { pinCode }); + await pollForTx(wsWallet, fundTx.hash!); + + // 4. Initialize FeeBlueprint contract + const initTx = await wsWallet.createAndSendNanoContractTransaction( + NANO_CONTRACTS_INITIALIZE_METHOD, + address0, + { + blueprintId: global.FEE_BLUEPRINT_ID, + args: [], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: 100n, + changeAddress: address0, + }, + ], + }, + { pinCode } + ); + await pollForTx(wsWallet, initTx.hash!); + contractId = initTx.hash!; + + // 5. Create FBT token + const createFbtTx = await wsWallet.createAndSendNanoContractTransaction( + 'create_fee_token', + address0, + { + ncId: contractId, + args: ['Fee Test Token', 'FBT', 1000], + actions: [ + { + type: 'deposit', + token: NATIVE_TOKEN_UID, + amount: 100n, + changeAddress: address0, + }, + ], + }, + { pinCode } + ); + await pollForTx(wsWallet, createFbtTx.hash!); + + // 6. Get fbtUid from contract state (with retry for fullnode indexing) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ncState = (await pollForNcState(contractId, ['fbt_uid'], 'fbt_uid')) as any; + fbtUid = ncState.fields.fbt_uid.value; + + // 7. Wait for wallet-service to sync the token details + // The token was just created, and getTokenDetails needs it to be indexed + await pollForTokenDetails(wsWallet, fbtUid); + }); + + afterAll(async () => { + if (wsWallet) { + await wsWallet.stop({ cleanStorage: true }); + } + await GenesisWalletServiceHelper.stop(); + }); + + it('should build tx without signing, edit caller, sign, and send', async () => { + const address0 = walletAddresses[0]; + const address1 = walletAddresses[1]; + + // 1. Withdraw some FBT from contract (to have tokens to deposit) + const withdrawTx = await wsWallet.createAndSendNanoContractTransaction( + 'noop', + address0, + { + ncId: contractId, + args: [], + actions: [ + { + type: 'withdrawal', + token: fbtUid, + amount: 10n, + address: address0, + }, + ], + }, + { pinCode } + ); + await pollForTx(wsWallet, withdrawTx.hash!); + await checkTxNotVoided(wsWallet, withdrawTx.hash!); + + const fbtDepositAmount = 5n; + const expectedFee = 2n; + + // 2. Build unsigned transaction with address0 as caller + const sendTransaction = await wsWallet.createNanoContractTransaction( + 'noop', + address0, + { + ncId: contractId, + args: [], + actions: [ + { + type: 'deposit', + token: fbtUid, + amount: fbtDepositAmount, + changeAddress: address0, + }, + ], + }, + { signTx: false } + ); + + const txBeforeChangeCaller = sendTransaction.transaction!; + expect(txBeforeChangeCaller).not.toBeNull(); + + // 3. Assert tx is built but NOT signed + expect(txBeforeChangeCaller.inputs.length).toBeGreaterThan(0); + for (const input of txBeforeChangeCaller.inputs) { + expect(input.data).toBeNull(); + } + + const nanoHeadersBeforeChangeCaller = txBeforeChangeCaller.getNanoHeaders(); + expect(nanoHeadersBeforeChangeCaller).toHaveLength(1); + expect(nanoHeadersBeforeChangeCaller[0].script).toBeNull(); + expect(nanoHeadersBeforeChangeCaller[0].address.base58).toBe(address0); + + // Outputs: FBT change + HTR change + expect(txBeforeChangeCaller.outputs).toHaveLength(2); + expect(txBeforeChangeCaller.outputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ tokenData: 1 }), // FBT + expect.objectContaining({ tokenData: 0 }), // HTR + ]) + ); + + // 4. Edit caller: change address AND seqnum for the new caller + await wsWallet.setNanoHeaderCaller(nanoHeadersBeforeChangeCaller[0], address1); + + // 5. Sign the transaction (signs both inputs AND nano header with new caller) + const txAfterSign = await wsWallet.signTx(txBeforeChangeCaller, { pinCode }); + const nanoHeadersAfterSign = txAfterSign.getNanoHeaders(); + + // 6. Assert tx IS now signed + for (const input of txAfterSign.inputs) { + expect(input.data).not.toBeNull(); + } + expect(nanoHeadersAfterSign[0].script).not.toBeNull(); + // Verify the caller was changed + expect(nanoHeadersAfterSign[0].address.base58).toBe(address1); + + // 7. Verify FeeHeader + const feeHeader = txAfterSign.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + expect(feeHeader!.entries[0].amount).toBe(expectedFee); + + // 8. Send and verify confirmed and not voided + const result = await sendTransaction.runFromMining(); + await pollForTx(wsWallet, result.hash!); + await checkTxNotVoided(wsWallet, result.hash!); + }); + + it('should build token creation tx without signing, edit caller, sign, and send', async () => { + const address0 = walletAddresses[0]; + const address1 = walletAddresses[1]; + + // 1. Build unsigned token creation transaction with address0 as caller + const sendTransaction = await wsWallet.createNanoContractCreateTokenTransaction( + 'noop', + address0, + { + ncId: contractId, + args: [], + actions: [], + }, + { + name: 'Test Token Unsigned WS', + symbol: 'TTUWS', + amount: 500n, + mintAddress: address0, + tokenVersion: TokenVersion.FEE, + contractPaysTokenDeposit: false, + changeAddress: address0, + createMint: true, + mintAuthorityAddress: address0, + createMelt: true, + meltAuthorityAddress: address0, + }, + { signTx: false } + ); + + const txBeforeChangeCaller = sendTransaction.transaction!; + expect(txBeforeChangeCaller).not.toBeNull(); + + // 2. Assert tx is built but NOT signed + // Inputs should exist (for HTR deposit) + expect(txBeforeChangeCaller.inputs.length).toBeGreaterThan(0); + for (const input of txBeforeChangeCaller.inputs) { + expect(input.data).toBeNull(); + } + + const nanoHeadersBeforeChangeCaller = txBeforeChangeCaller.getNanoHeaders(); + expect(nanoHeadersBeforeChangeCaller).toHaveLength(1); + expect(nanoHeadersBeforeChangeCaller[0].script).toBeNull(); + expect(nanoHeadersBeforeChangeCaller[0].address.base58).toBe(address0); + + // Outputs should include token mint outputs and HTR change + expect(txBeforeChangeCaller.outputs.length).toBeGreaterThan(0); + expect(txBeforeChangeCaller.outputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tokenData: 0, // HTR change + }), + ]) + ); + + // 3. Edit caller: change address AND seqnum for the new caller + await wsWallet.setNanoHeaderCaller(nanoHeadersBeforeChangeCaller[0], address1); + + // 4. Sign the transaction (signs both inputs AND nano header with new caller) + const txAfterSign = await wsWallet.signTx(txBeforeChangeCaller, { pinCode }); + const nanoHeadersAfterSign = txAfterSign.getNanoHeaders(); + + // 5. Assert tx IS now signed + for (const input of txAfterSign.inputs) { + expect(input.data).not.toBeNull(); + } + expect(nanoHeadersAfterSign[0].script).not.toBeNull(); + // Verify the caller was changed + expect(nanoHeadersAfterSign[0].address.base58).toBe(address1); + + // 6. Verify FeeHeader exists for token creation + const feeHeader = txAfterSign.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + + // 7. Send and verify confirmed and not voided + const result = await sendTransaction.runFromMining(); + await pollForTx(wsWallet, result.hash!); + await checkTxNotVoided(wsWallet, result.hash!); + + // 8. Verify token was created + const newTokenUid = result.hash; + expect(newTokenUid).toBeDefined(); + }); +}); diff --git a/__tests__/new/hathorwallet.test.ts b/__tests__/new/hathorwallet.test.ts index b939a9aa8..155906e66 100644 --- a/__tests__/new/hathorwallet.test.ts +++ b/__tests__/new/hathorwallet.test.ts @@ -285,22 +285,32 @@ test('getSignatures', async () => { }); test('signTx', async () => { - const hWallet = new FakeHathorWallet(); - hWallet.getSignatures.mockImplementation(() => - Promise.resolve([ - { - inputIndex: 0, - signature: 'ca', - pubkey: 'fe', - }, - { - inputIndex: 2, - signature: 'ba', - pubkey: 'be', - }, - ]) + const store = new MemoryStore(); + const storage = new Storage(store); + jest.spyOn(storage, 'isReadonly').mockReturnValue(Promise.resolve(false)); + jest.spyOn(storage, 'getTxSignatures').mockReturnValue( + Promise.resolve({ + ncCallerSignature: null, + inputSignatures: [ + { + signature: Buffer.from('ca', 'hex'), + pubkey: Buffer.from('fe', 'hex'), + inputIndex: 0, + addressIndex: 0, + }, + { + signature: Buffer.from('ba', 'hex'), + pubkey: Buffer.from('be', 'hex'), + inputIndex: 2, + addressIndex: 1, + }, + ], + }) ); + const hWallet = new FakeHathorWallet(); + hWallet.storage = storage; + const txId = '000164e1e7ec7700a18750f9f50a1a9b63f6c7268637c072ae9ee181e58eb01b'; const tx = new Transaction([new Input(txId, 0), new Input(txId, 1), new Input(txId, 2)], [], { version: DEFAULT_TX_VERSION, @@ -309,12 +319,38 @@ test('signTx', async () => { const returnedTx = await hWallet.signTx(tx, { pinCode: '123' }); expect(returnedTx).toBe(tx); - expect(hWallet.getSignatures).toHaveBeenCalledWith(tx, { pinCode: '123' }); + expect(storage.getTxSignatures).toHaveBeenCalledWith(tx, '123'); expect(tx.inputs[0].data.toString('hex')).toEqual('01ca01fe'); expect(tx.inputs[1].data).toEqual(null); expect(tx.inputs[2].data.toString('hex')).toEqual('01ba01be'); }); +test('signTx throws when pinCode is not provided', async () => { + const store = new MemoryStore(); + const storage = new Storage(store); + jest.spyOn(storage, 'isReadonly').mockReturnValue(Promise.resolve(false)); + + const hWallet = new FakeHathorWallet(); + hWallet.storage = storage; + // Ensure wallet has no pinCode set + hWallet.pinCode = null; + + const txId = '000164e1e7ec7700a18750f9f50a1a9b63f6c7268637c072ae9ee181e58eb01b'; + const tx = new Transaction([new Input(txId, 0)], [], { + version: DEFAULT_TX_VERSION, + tokens: [], + }); + + // Should throw when no pinCode is provided in options and wallet.pinCode is null + await expect(hWallet.signTx(tx)).rejects.toThrow('Pin code is required to sign a transaction'); + await expect(hWallet.signTx(tx, {})).rejects.toThrow( + 'Pin code is required to sign a transaction' + ); + await expect(hWallet.signTx(tx, { pinCode: null })).rejects.toThrow( + 'Pin code is required to sign a transaction' + ); +}); + test('getWalletInputInfo', async () => { const store = new MemoryStore(); const storage = new Storage(store); diff --git a/src/lib.ts b/src/lib.ts index bd8f50c53..8ac2fd786 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -41,6 +41,7 @@ import * as bufferUtils from './utils/buffer'; import HathorWalletServiceWallet from './wallet/wallet'; import walletServiceApi from './wallet/api/walletApi'; import SendTransactionWalletService from './wallet/sendTransactionWalletService'; +import { WalletServiceStorageProxy } from './wallet/walletServiceStorageProxy'; import config from './config'; import * as PushNotification from './pushNotification'; import { WalletType, HistorySyncMode } from './types'; @@ -112,6 +113,7 @@ export { HathorWalletServiceWallet, walletServiceApi, SendTransactionWalletService, + WalletServiceStorageProxy, config, PushNotification, swapService, diff --git a/src/nano_contracts/builder.ts b/src/nano_contracts/builder.ts index a992bc697..917a66e07 100644 --- a/src/nano_contracts/builder.ts +++ b/src/nano_contracts/builder.ts @@ -909,7 +909,7 @@ class NanoContractTransactionBuilder { ); } - if (fee > 0n && !this.contractPaysFees) { + if (fee > 0n && !this.contractPaysFees && !this.tokenFeeAddedInDeposit) { const { inputs: feeInputs, outputs: feeOutputs } = await this.selectFeeInputs(fee); inputs = concat(inputs, feeInputs); outputs = concat(outputs, feeOutputs); diff --git a/src/nano_contracts/types.ts b/src/nano_contracts/types.ts index 85ac7eff7..b4ac178a6 100644 --- a/src/nano_contracts/types.ts +++ b/src/nano_contracts/types.ts @@ -233,6 +233,8 @@ export type CreateNanoTxOptions = { maxFee?: OutputValueType; /** If the contract will pay the transaction fees (for FEE tokens) */ contractPaysFees?: boolean; + /** If the transaction should be signed */ + signTx?: boolean; }; export interface NanoContractBlueprintSourceCodeAPIResponse { diff --git a/src/nano_contracts/utils.ts b/src/nano_contracts/utils.ts index 479cb62e6..49d1910af 100644 --- a/src/nano_contracts/utils.ts +++ b/src/nano_contracts/utils.ts @@ -35,6 +35,30 @@ import { NANO_CONTRACTS_INITIALIZE_METHOD, TOKEN_MELT_MASK, TOKEN_MINT_MASK } fr import { getFieldParser, normalizeTypeString } from './ncTypes/parser'; import { isSignedDataField } from './fields'; import HathorWallet from '../new/wallet'; +import NanoContractHeader from './header'; + +/** + * Set the caller address and seqnum on a nano contract header. + * This is a shared utility used by both HathorWallet and HathorWalletServiceWallet. + * + * @param nanoHeader The nano contract header to modify + * @param address The new caller address string + * @param wallet The wallet instance to get network and seqnum from + */ +export const setNanoHeaderCallerFromWallet = async ( + nanoHeader: NanoContractHeader, + address: string, + wallet: IHathorWallet +): Promise => { + const newAddress = new Address(address, { network: wallet.getNetworkObject() }); + newAddress.validateAddress(); + + const newCallerSeqnum = await wallet.getNanoHeaderSeqnum(address); + // eslint-disable-next-line no-param-reassign + nanoHeader.address = newAddress; + // eslint-disable-next-line no-param-reassign + nanoHeader.seqnum = newCallerSeqnum; +}; /** * Sign a transaction and create a send transaction object diff --git a/src/new/wallet.ts b/src/new/wallet.ts index 46ec63875..2b8055bf5 100644 --- a/src/new/wallet.ts +++ b/src/new/wallet.ts @@ -91,7 +91,7 @@ import txApi from '../api/txApi'; import { MemoryStore, Storage } from '../storage'; import { deriveAddressP2PKH, deriveAddressP2SH, getAddressFromPubkey } from '../utils/address'; import NanoContractTransactionBuilder from '../nano_contracts/builder'; -import { prepareNanoSendTransaction } from '../nano_contracts/utils'; +import { prepareNanoSendTransaction, setNanoHeaderCallerFromWallet } from '../nano_contracts/utils'; import OnChainBlueprint, { Code, CodeKind } from '../nano_contracts/on_chain_blueprint'; import { NanoContractBuilderCreateTokenOptions, @@ -142,6 +142,7 @@ import { GraphvizNeighboursResponse, TransactionAccWeightResponse, } from '../api/schemas/txApi'; +import NanoContractHeader from '../nano_contracts/header'; const ERROR_MESSAGE_PIN_REQUIRED = 'Pin is required.'; @@ -2737,25 +2738,25 @@ class HathorWallet extends EventEmitter { /** * Sign all inputs of the given transaction. - * OBS: only for P2PKH wallets. * * @param tx - The transaction to be signed * @param options - Options for signing - * @param options.pinCode - PIN to decrypt the private key. Optional but required if not set in this + * @param options.pinCode - PIN to decrypt the private key * * @returns The signed transaction */ async signTx(tx: Transaction, options: { pinCode?: string | null } = {}): Promise { - for (const sigInfo of await this.getSignatures(tx, options)) { - const { signature, pubkey, inputIndex } = sigInfo; - const inputData = transactionUtils.createInputData( - Buffer.from(signature, 'hex'), - Buffer.from(pubkey, 'hex') - ); - tx.inputs[inputIndex].setData(inputData); + if (await this.isReadonly()) { + throw new WalletFromXPubGuard('signTx'); + } + const pinCode = options.pinCode ?? this.pinCode; + if (!pinCode) { + throw new Error('Pin code is required to sign a transaction'); } - return tx; + const signedTx = await transactionUtils.signTransaction(tx, this.storage, pinCode); + signedTx.prepareToSend(); + return signedTx; } /** @@ -3027,14 +3028,12 @@ class HathorWallet extends EventEmitter { method: string, address: string, data: CreateNanoTxData, - options: CreateNanoTxOptions = {} + options: Omit = {} ): Promise { - const sendTransaction = await this.createNanoContractTransaction( - method, - address, - data, - options - ); + const sendTransaction = await this.createNanoContractTransaction(method, address, data, { + ...options, + signTx: true, + }); return sendTransaction.runFromMining(); } @@ -3055,9 +3054,11 @@ class HathorWallet extends EventEmitter { if (await this.storage.isReadonly()) { throw new WalletFromXPubGuard('createNanoContractTransaction'); } - const newOptions = { pinCode: null, ...options }; + const newOptions = { pinCode: null, signTx: true, ...options }; const pin = newOptions.pinCode || this.pinCode; - if (!pin) { + + // Only require PIN if we're actually signing + if (newOptions.signTx !== false && !pin) { throw new PinRequiredError(ERROR_MESSAGE_PIN_REQUIRED); } @@ -3091,7 +3092,14 @@ class HathorWallet extends EventEmitter { } const nc = await builder.build(); - return prepareNanoSendTransaction(nc, pin, this.storage); + if (newOptions.signTx !== false) { + return prepareNanoSendTransaction(nc, pin, this.storage); + } + + return new SendTransaction({ + storage: this.storage, + transaction: nc, + }); } /** @@ -3139,7 +3147,7 @@ class HathorWallet extends EventEmitter { if (await this.storage.isReadonly()) { throw new WalletFromXPubGuard('createNanoContractCreateTokenTransaction'); } - const newOptions = { pinCode: null, ...options }; + const newOptions = { pinCode: null, signTx: true, ...options }; const pin = newOptions.pinCode || this.pinCode; if (!pin) { throw new PinRequiredError(ERROR_MESSAGE_PIN_REQUIRED); @@ -3221,7 +3229,13 @@ class HathorWallet extends EventEmitter { } const nc = await builder.build(); - return prepareNanoSendTransaction(nc, pin, this.storage); + if (newOptions.signTx !== false) { + return prepareNanoSendTransaction(nc, pin, this.storage); + } + return new SendTransaction({ + storage: this.storage, + transaction: nc, + }); } /** @@ -3443,6 +3457,23 @@ class HathorWallet extends EventEmitter { return addressInfo.seqnum + 1; } + /** + * Set the caller address and seqnum on a nano contract header + * + * @param nanoHeader The nano contract header to modify + * @param address The new caller address + */ + async setNanoHeaderCaller(nanoHeader: NanoContractHeader, address: string): Promise { + const addressInfo = await this.storage.getAddressInfo(address); + if (!addressInfo) { + throw new NanoContractTransactionError( + `Address used to sign the transaction (${address}) does not belong to the wallet.` + ); + } + + await setNanoHeaderCallerFromWallet(nanoHeader, address, this); + } + // eslint-disable-next-line class-methods-use-this async startReadOnly( options?: { skipAddressFetch?: boolean | undefined } | undefined diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 0ae87b3f6..300ce8c6f 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -12,7 +12,8 @@ import CreateTokenTransaction from '../models/create_token_transaction'; import SendTransactionWalletService from './sendTransactionWalletService'; import Input from '../models/input'; import Output from '../models/output'; -import { CreateNanoTxData } from '../nano_contracts/types'; +import { CreateNanoTxData, CreateNanoTxOptions } from '../nano_contracts/types'; +import NanoContractHeader from '../nano_contracts/header'; // Type used in create token methods so we can have defaults for required params export type CreateTokenOptionsInput = { @@ -427,28 +428,29 @@ export interface IHathorWallet { method: string, address: string, data: CreateNanoTxData, - options?: { pinCode?: string } + options?: CreateNanoTxOptions ): Promise; createAndSendNanoContractTransaction( method: string, address: string, data: CreateNanoTxData, - options?: { pinCode?: string } + options?: CreateNanoTxOptions ): Promise; createNanoContractCreateTokenTransaction( method: string, address: string, data: CreateNanoTxData, createTokenOptions: CreateTokenOptionsInput, - options?: { pinCode?: string } + options?: CreateNanoTxOptions ): Promise; createAndSendNanoContractCreateTokenTransaction( method: string, address: string, data: CreateNanoTxData, createTokenOptions: CreateTokenOptionsInput, - options?: { pinCode?: string } + options?: CreateNanoTxOptions ): Promise; + getNanoHeaderSeqnum(address: string): Promise; isAddressMine(address: string): Promise; getUtxosForAmount( amount: OutputValueType, @@ -484,6 +486,8 @@ export interface IHathorWallet { hasTxOutsideFirstAddress(): Promise; pinCode?: string | null; storage: IStorage; + signTx(tx: Transaction, options: { pinCode?: string | null }): Promise; + setNanoHeaderCaller(nanoHeader: NanoContractHeader, address: string): Promise; } export interface ISendTransaction { diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index 3cd15f5cd..26605638c 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -80,12 +80,14 @@ import { TokenNotFoundError, } from '../errors'; import NanoContractTransactionBuilder from '../nano_contracts/builder'; +import NanoContractHeader from '../nano_contracts/header'; import { NanoContractVertexType, NanoContractBuilderCreateTokenOptions, CreateNanoTxData, CreateNanoTxOptions, } from '../nano_contracts/types'; +import { setNanoHeaderCallerFromWallet } from '../nano_contracts/utils'; import { WalletServiceStorageProxy } from './walletServiceStorageProxy'; import HathorWallet from '../new/wallet'; import { ErrorMessages } from '../errorMessages'; @@ -1610,6 +1612,16 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { return addressInfo.data.seqnum + 1; } + /** + * Set the caller address and seqnum on a nano contract header + * + * @param nanoHeader The nano contract header to modify + * @param address The new caller address + */ + async setNanoHeaderCaller(nanoHeader: NanoContractHeader, address: string): Promise { + await setNanoHeaderCallerFromWallet(nanoHeader, address, this); + } + /** * Get detailed information about a specific address from the wallet service * @@ -2801,17 +2813,19 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { throw new WalletFromXPubGuard('createNanoContractTransaction'); } - const newOptions = { pinCode: null, ...options }; + const newOptions = { pinCode: null, signTx: true, ...options }; const pin = newOptions.pinCode; - if (!pin) { + + // Only require PIN if we're actually signing + if (newOptions.signTx !== false && !pin) { throw new PinRequiredError('Pin is required.'); } - // Verify address belongs to wallet and get its index - const addressIndex = await this.getAddressIndexIfOwned(address); + // Verify address belongs to wallet + await this.getAddressIndexIfOwned(address); // Get the caller address - const callerAddress = await this.getCallerAddressFromIndex(pin, addressIndex); + const callerAddress = new Address(address, { network: this.getNetworkObject() }); // Build and send transaction const actions = data.actions || []; @@ -2839,7 +2853,9 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { const tx = await builder.build(); // Use the standard utility to sign and prepare the transaction - return this.prepareNanoSendTransactionWalletService(tx, address, pin); + return this.prepareNanoSendTransactionWalletService(tx, address, pin!, { + signTx: newOptions.signTx, + }); } /** @@ -2848,7 +2864,7 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { * @param {string} method Method of nano contract to have the transaction created * @param {string} address Address that will be used to sign the nano contract transaction * @param {CreateNanoTxData} [data] - * @param {CreateNanoTxOptions} [options] + * @param {Omit} [options] * * @returns {Promise} Transaction object returned from execution */ @@ -2856,14 +2872,13 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { method: string, address: string, data: CreateNanoTxData, - options: CreateNanoTxOptions = {} + options: Omit = {} ): Promise { - const sendTransaction = await this.createNanoContractTransaction( - method, - address, - data, - options - ); + const sendTransaction = await this.createNanoContractTransaction(method, address, data, { + ...options, + signTx: true, + }); + const result = await sendTransaction.runFromMining(); if (!result) { throw new Error('Failed to send nano contract transaction'); @@ -2915,7 +2930,8 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { async prepareNanoSendTransactionWalletService( tx: Transaction, address: string, - pinCode: string + pinCode: string | null, + options: { signTx?: boolean } = { signTx: true } ): Promise { // Get the index for the address const addressDetails = await this.getAddressDetails(address); @@ -2927,12 +2943,9 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { ); } - const storageProxy = new WalletServiceStorageProxy(this, this.storage); - - await transaction.signTransaction(tx, storageProxy.createProxy(), pinCode); - - // Finalize the transaction - tx.prepareToSend(); + if (options.signTx !== false && pinCode) { + await this.signTx(tx, { pinCode }); + } const sendTransaction = new SendTransactionWalletService(this, { transaction: tx, @@ -2963,17 +2976,19 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { if (await this.storage.isReadonly()) { throw new WalletFromXPubGuard('createNanoContractCreateTokenTransaction'); } - const newOptions = { pinCode: null, ...options }; + const newOptions = { pinCode: null, signTx: true, ...options }; const pin = newOptions.pinCode; - if (!pin) { + + // Only require PIN if we're actually signing + if (newOptions.signTx !== false && !pin) { throw new PinRequiredError('Pin is required.'); } - // Verify address belongs to wallet and get its index - const addressIndex = await this.getAddressIndexIfOwned(address); + // // Verify address belongs to wallet + await this.getAddressIndexIfOwned(address); // Get the caller address - const callerAddress = await this.getCallerAddressFromIndex(pin, addressIndex); + const callerAddress = new Address(address, { network: this.getNetworkObject() }); // Build and send transaction const actions = data.actions || []; @@ -3034,6 +3049,33 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { const data = await walletApi.getHasTxOutsideFirstAddress(this); return data.hasTransactions; } + + /** + * Sign all inputs of the given transaction. + * + * @param tx - The transaction to be signed + * @param options - Options for signing + * @param options.pinCode - PIN to decrypt the private key. Optional but required if not set in this + * + * @returns The signed transaction + */ + async signTx(tx: Transaction, options: { pinCode?: string | null } = {}): Promise { + if (await this.storage.isReadonly()) { + throw new WalletFromXPubGuard('signTx'); + } + if (!options.pinCode) { + throw new Error('Pin code is required to sign a transaction'); + } + + const storageProxy = new WalletServiceStorageProxy(this, this.storage); + const signedTx = await transaction.signTransaction( + tx, + storageProxy.createProxy(), + options.pinCode + ); + signedTx.prepareToSend(); + return signedTx; + } } export default HathorWalletServiceWallet;