diff --git a/.pnp.js b/.pnp.js index 3e919aea..1fb4c851 100755 --- a/.pnp.js +++ b/.pnp.js @@ -50,6 +50,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@typescript-eslint/parser", "virtual:6f50bb9424c73c7612c66dab5cf8914d8ec79550c84d8ca5e4888e80022682c708b4b5a1c510d282a03285cc9bb19002b477ae70d15882aa995ea1d5d6bf24ab#npm:4.20.0"], ["ardrive-core-js", "npm:1.0.4"], ["arweave", "npm:1.10.16"], + ["axios", "npm:0.21.1"], ["chai", "npm:4.3.4"], ["commander", "npm:8.3.0"], ["eslint", "npm:7.23.0"], @@ -1294,6 +1295,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@typescript-eslint/parser", "virtual:6f50bb9424c73c7612c66dab5cf8914d8ec79550c84d8ca5e4888e80022682c708b4b5a1c510d282a03285cc9bb19002b477ae70d15882aa995ea1d5d6bf24ab#npm:4.20.0"], ["ardrive-core-js", "npm:1.0.4"], ["arweave", "npm:1.10.16"], + ["axios", "npm:0.21.1"], ["chai", "npm:4.3.4"], ["commander", "npm:8.3.0"], ["eslint", "npm:7.23.0"], diff --git a/README.md b/README.md index b6b28999..ea8b137e 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ ardrive upload-file --wallet-file /path/to/my/wallet.json --parent-folder-id "f0 2. [Dealing With Network Congestion](#dealing-with-network-congestion) 3. [Check for network congestion before uploading](#check-congestion) 4. [Front-run Congestion By Boosting Miner Rewards](#boost) + 5. [Send AR Transactions From a Cold Wallet](#cold-tx) 4. [All ArDrive CLI Commands](#all-ardrive-cli-commands) 5. [Getting Help](#getting-help) @@ -835,6 +836,40 @@ ardrive get-mempool | jq 'length' ardrive upload-file --wallet-file /path/to/my/wallet.json --parent-folder-id "f0c58c11-430c-4383-8e54-4d864cc7e927" --local-file-path ./helloworld.txt --boost 1.5 ``` +#### Send AR Transactions From a Cold Wallet + +The best cold wallet storage never exposes your seed phrase and/or private keys to the Internet or a compromised system interface. You can use the ArDrive CLI to facilitate cold storage and transfer of AR. + +If you need a new cold AR wallet, generate one from an airgapped machine capable of running the ArDrive CLI by following the instructions in the [Wallet Operations](#wallet-operations) section. Fund your cold wallet from whatever external sources you'd like. NOTE: Your cold wallet won't appear on chain until it has received AR. + +The workflow to send the AR out from your cold wallet requires you to generate a signed transaction with your cold wallet on your airgapped machine via the ArDrive CLI, and then to transfer the signed transaction (e.g. by a file on a clean thumb drive) to an Internet-connected machine and send the transaction to the network via the ArDrive CLI. You'll need two inputs from the Internect-connected machine: +• the last transaction sent OUT from the cold wallet (or an empty string if none has ever been sent out) +• the base fee for an Arweave transaction (i.e. a zero bye transaction). Note that this value could change if a sufficient amount of time passes between the time you fetch this value, create the transaction, and send the transaction. + +To get the last transaction sent from your cold wallet, use the `last-tx` command and specify your wallet address e.g.: + +``` +ardrive last-tx -a +``` + +To get the base transaction reward required for an AR transaction, use the `base-reward` function, optionally applying a reward boost multiple if you're looking to front-run network congestion: + +``` +ardrive base-reward --boost 1.5 +``` + +Write down or securely copy the values you derived from the Internet-connected machine and run the following commands on the airgapped machine, piping the outputted signed transaction data to a file in the process, e.g. `sendme.json` (if that's your signed transaction transfer medium preference): + +``` +ardrive create-tx -w /path/to/wallet/file.json -d -a --last-tx --reward "" > sendme.json +``` + +Transport your signed transaction to the Internet-connected machine and run the following command to send your transaction to the Arweave network: + +``` +ardrive send-tx -x /path/to/sendme.json +``` + # All ArDrive CLI Commands ```shell @@ -885,12 +920,16 @@ send-ar get-drive-key get-file-key +last-tx + Arweave Ops =========== -tx-status +base-reward get-mempool - +create-tx +send-tx +tx-status # Learn more about a command: ardrive --help diff --git a/package.json b/package.json index 69904d5f..afe9e26d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dependencies": { "ardrive-core-js": "1.0.4", "arweave": "^1.10.16", + "axios": "^0.21.1", "commander": "^8.2.0", "lodash": "^4.17.21", "prompts": "^2.4.0" diff --git a/src/commands/base_reward.ts b/src/commands/base_reward.ts new file mode 100644 index 00000000..8df8fef9 --- /dev/null +++ b/src/commands/base_reward.ts @@ -0,0 +1,27 @@ +import { ByteCount } from 'ardrive-core-js'; +import { CLICommand, ParametersHelper } from '../CLICommand'; +import { CLIAction } from '../CLICommand/action'; +import { SUCCESS_EXIT_CODE } from '../CLICommand/error_codes'; +import { BoostParameter } from '../parameter_declarations'; +import axios, { AxiosResponse } from 'axios'; + +async function getBaseReward(byteCount?: ByteCount): Promise { + const response: AxiosResponse = await axios.get(`https://arweave.net/price/${byteCount ?? 0}`); + return `${response.data}`; +} + +new CLICommand({ + name: 'base-reward', + parameters: [BoostParameter], + action: new CLIAction(async function action(options) { + const parameters = new ParametersHelper(options); + let baseRewardStr = await getBaseReward(); + const multiple = parameters.getOptionalBoostSetting(); + if (multiple) { + baseRewardStr = multiple.boostReward(baseRewardStr); + } + + console.log(baseRewardStr); + return SUCCESS_EXIT_CODE; + }) +}); diff --git a/src/commands/create_tx.ts b/src/commands/create_tx.ts new file mode 100644 index 00000000..696948f6 --- /dev/null +++ b/src/commands/create_tx.ts @@ -0,0 +1,52 @@ +import { ADDR, AR, JWKWallet, TxID, W, Winston } from 'ardrive-core-js'; +import { CreateTransactionInterface } from 'arweave/node/common'; +import { cliArweave, CLI_APP_NAME, CLI_APP_VERSION } from '..'; +import { CLICommand } from '../CLICommand'; +import { ParametersHelper } from '../CLICommand'; +import { CLIAction } from '../CLICommand/action'; +import { SUCCESS_EXIT_CODE } from '../CLICommand/error_codes'; +import { + ArAmountParameter, + DestinationAddressParameter, + LastTxParameter, + RewardParameter, + WalletTypeParameters +} from '../parameter_declarations'; + +new CLICommand({ + name: 'create-tx', + parameters: [ + ArAmountParameter, + DestinationAddressParameter, + RewardParameter, + LastTxParameter, + ...WalletTypeParameters + ], + action: new CLIAction(async function action(options) { + const parameters = new ParametersHelper(options); + const arAmount = parameters.getRequiredParameterValue(ArAmountParameter, AR.from); + const winston: Winston = arAmount.toWinston(); + const destAddress = parameters.getRequiredParameterValue(DestinationAddressParameter, ADDR); + const jwkWallet = (await parameters.getRequiredWallet()) as JWKWallet; + const lastTxParam = parameters.getParameterValue(LastTxParameter); // Can be provided as a txID or empty string + const last_tx = lastTxParam && lastTxParam.length ? `${TxID(lastTxParam)}` : undefined; + + // Create and sign transaction + const trxAttributes: Partial = { + target: destAddress.toString(), + quantity: winston.toString(), + reward: `${parameters.getRequiredParameterValue(RewardParameter, W)}`, + last_tx + }; + const transaction = await cliArweave.createTransaction(trxAttributes, jwkWallet.getPrivateKey()); + transaction.addTag('App-Name', CLI_APP_NAME); + transaction.addTag('App-Version', CLI_APP_VERSION); + transaction.addTag('Type', 'transfer'); + + await cliArweave.transactions.sign(transaction, jwkWallet.getPrivateKey()); + + console.log(JSON.stringify(transaction)); + + return SUCCESS_EXIT_CODE; + }) +}); diff --git a/src/commands/get_address.ts b/src/commands/get_address.ts index 47a90815..8d227ef2 100644 --- a/src/commands/get_address.ts +++ b/src/commands/get_address.ts @@ -1,11 +1,11 @@ import { CLICommand, ParametersHelper } from '../CLICommand'; import { CLIAction } from '../CLICommand/action'; import { SUCCESS_EXIT_CODE } from '../CLICommand/error_codes'; -import { SeedPhraseParameter, WalletFileParameter } from '../parameter_declarations'; +import { WalletTypeParameters } from '../parameter_declarations'; new CLICommand({ name: 'get-address', - parameters: [WalletFileParameter, SeedPhraseParameter], + parameters: [...WalletTypeParameters], action: new CLIAction(async function action(options) { const parameters = new ParametersHelper(options); const address = await parameters.getWalletAddress(); diff --git a/src/commands/get_balance.ts b/src/commands/get_balance.ts index 4ceb9e6c..aa1c9351 100644 --- a/src/commands/get_balance.ts +++ b/src/commands/get_balance.ts @@ -3,11 +3,11 @@ import { cliWalletDao } from '..'; import { CLICommand, ParametersHelper } from '../CLICommand'; import { CLIAction } from '../CLICommand/action'; import { SUCCESS_EXIT_CODE } from '../CLICommand/error_codes'; -import { AddressParameter, SeedPhraseParameter, WalletFileParameter } from '../parameter_declarations'; +import { AddressParameter, WalletTypeParameters } from '../parameter_declarations'; new CLICommand({ name: 'get-balance', - parameters: [WalletFileParameter, SeedPhraseParameter, AddressParameter], + parameters: [...WalletTypeParameters, AddressParameter], action: new CLIAction(async function action(options) { const parameters = new ParametersHelper(options); const address = await parameters.getWalletAddress(); diff --git a/src/commands/index.ts b/src/commands/index.ts index f2275492..c7992096 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,21 +1,25 @@ import '../parameter_declarations'; +import './base_reward'; import './create_drive'; +import './create_folder'; +import './create_tx'; import './drive_info'; -import './upload_file'; -import './tx_status'; -import './get_mempool'; -import './send_ar'; -import './get_balance'; -import './get_address'; +import './file_info'; +import './folder_info'; import './generate_seedphrase'; import './generate_wallet'; -import './list_folder'; -import './list_drive'; +import './get_address'; +import './get_balance'; +import './get_drive_key'; +import './get_file_key'; +import './get_mempool'; +import './last_tx'; import './list_all_drives'; -import './folder_info'; -import './create_folder'; -import './file_info'; +import './list_drive'; +import './list_folder'; import './move_file'; import './move_folder'; -import './get_drive_key'; -import './get_file_key'; +import './send_ar'; +import './send_tx'; +import './tx_status'; +import './upload_file'; diff --git a/src/commands/last_tx.ts b/src/commands/last_tx.ts new file mode 100644 index 00000000..5c22f663 --- /dev/null +++ b/src/commands/last_tx.ts @@ -0,0 +1,23 @@ +import { ArweaveAddress } from 'ardrive-core-js'; +import { CLICommand, ParametersHelper } from '../CLICommand'; +import { CLIAction } from '../CLICommand/action'; +import { SUCCESS_EXIT_CODE } from '../CLICommand/error_codes'; +import { AddressParameter, WalletTypeParameters } from '../parameter_declarations'; +import axios, { AxiosResponse } from 'axios'; + +async function lastTxForAddress(address: ArweaveAddress): Promise { + const response: AxiosResponse = await axios.get(`https://arweave.net/wallet/${address}/last_tx`); + return `${response.data}`; +} + +new CLICommand({ + name: 'last-tx', + parameters: [...WalletTypeParameters, AddressParameter], + action: new CLIAction(async function action(options) { + const parameters = new ParametersHelper(options); + const walletAddress = await parameters.getWalletAddress(); + const lastTx = await lastTxForAddress(walletAddress); + console.log(lastTx); + return SUCCESS_EXIT_CODE; + }) +}); diff --git a/src/commands/send_ar.ts b/src/commands/send_ar.ts index 082acdcb..e7319fd0 100644 --- a/src/commands/send_ar.ts +++ b/src/commands/send_ar.ts @@ -9,12 +9,18 @@ import { BoostParameter, DestinationAddressParameter, DryRunParameter, - WalletFileParameter + WalletTypeParameters } from '../parameter_declarations'; new CLICommand({ name: 'send-ar', - parameters: [ArAmountParameter, DestinationAddressParameter, WalletFileParameter, BoostParameter, DryRunParameter], + parameters: [ + ArAmountParameter, + DestinationAddressParameter, + BoostParameter, + DryRunParameter, + ...WalletTypeParameters + ], action: new CLIAction(async function action(options) { const parameters = new ParametersHelper(options); const arAmount = parameters.getRequiredParameterValue(ArAmountParameter, AR.from); diff --git a/src/commands/send_tx.ts b/src/commands/send_tx.ts new file mode 100644 index 00000000..c49fe015 --- /dev/null +++ b/src/commands/send_tx.ts @@ -0,0 +1,54 @@ +import { cliArweave } from '..'; +import { CLICommand, ParametersHelper } from '../CLICommand'; +import { DryRunParameter, TxFilePathParameter } from '../parameter_declarations'; +import * as fs from 'fs'; +import Transaction from 'arweave/node/lib/transaction'; +import * as crypto from 'crypto'; +import { ERROR_EXIT_CODE, SUCCESS_EXIT_CODE } from '../CLICommand/error_codes'; +import { CLIAction } from '../CLICommand/action'; +import { b64UrlToBuffer, bufferTob64Url } from 'ardrive-core-js'; + +new CLICommand({ + name: 'send-tx', + parameters: [TxFilePathParameter, DryRunParameter], + action: new CLIAction(async function action(options) { + const parameters = new ParametersHelper(options); + const transaction = new Transaction( + JSON.parse(fs.readFileSync(parameters.getRequiredParameterValue(TxFilePathParameter)).toString()) + ); + const srcAddress = bufferTob64Url( + crypto.createHash('sha256').update(b64UrlToBuffer(transaction.owner)).digest() + ); + + console.log(`Source address: ${srcAddress}`); + console.log(`AR amount sent: ${cliArweave.ar.winstonToAr(transaction.quantity)}`); + console.log(`Destination address: ${transaction.target}`); + + const response = await (async () => { + if (options.dryRun) { + return { status: 200, statusText: 'OK', data: '' }; + } else { + return await cliArweave.transactions.post(transaction); + } + })(); + if (response.status === 200 || response.status === 202) { + console.log( + JSON.stringify( + { + txID: transaction.id, + winston: transaction.quantity, + reward: transaction.reward + }, + null, + 4 + ) + ); + + return SUCCESS_EXIT_CODE; + } else { + console.log(`Failed to send tx with error: ${response.statusText}`); + } + + return ERROR_EXIT_CODE; + }) +}); diff --git a/src/parameter_declarations.ts b/src/parameter_declarations.ts index f0c43269..9c96964c 100644 --- a/src/parameter_declarations.ts +++ b/src/parameter_declarations.ts @@ -10,6 +10,9 @@ export const DriveKeyParameter = 'driveKey'; export const AddressParameter = 'address'; export const DriveIdParameter = 'driveId'; export const ArAmountParameter = 'arAmount'; +export const RewardParameter = 'reward'; +export const LastTxParameter = 'lastTx'; +export const TxFilePathParameter = 'txFilePath'; export const DestinationAddressParameter = 'destAddress'; export const TransactionIdParameter = 'txId'; export const ConfirmationsParameter = 'confirmations'; @@ -31,40 +34,39 @@ export const UpsertParameter = 'upsert'; export const NoVerifyParameter = 'verify'; // commander maps --no-x style params to options.x and always includes in options // Aggregates for convenience -export const DriveCreationPrivacyParameters = [ - PrivateParameter, - UnsafeDrivePasswordParameter, - WalletFileParameter, - SeedPhraseParameter -]; +export const WalletTypeParameters = [WalletFileParameter, SeedPhraseParameter]; +export const DriveCreationPrivacyParameters = [...WalletTypeParameters, PrivateParameter, UnsafeDrivePasswordParameter]; export const DrivePrivacyParameters = [DriveKeyParameter, ...DriveCreationPrivacyParameters]; export const TreeDepthParams = [AllParameter, MaxDepthParameter]; export const AllParameters = [ - WalletFileParameter, - SeedPhraseParameter, - PrivateParameter, - UnsafeDrivePasswordParameter, - DriveNameParameter, - FolderNameParameter, - DriveKeyParameter, AddressParameter, - DriveIdParameter, + AllParameter, ArAmountParameter, - DestinationAddressParameter, - TransactionIdParameter, + BoostParameter, ConfirmationsParameter, - FolderIdParameter, - FileIdParameter, - ParentFolderIdParameter, - LocalFilePathParameter, + DestinationAddressParameter, DestinationFileNameParameter, - LocalFilesParameter, + DriveKeyParameter, + DriveNameParameter, + DriveIdParameter, + DryRunParameter, + FileIdParameter, + FolderIdParameter, + FolderNameParameter, GetAllRevisionsParameter, - AllParameter, + LastTxParameter, + LocalFilesParameter, + LocalFilePathParameter, MaxDepthParameter, - BoostParameter, - DryRunParameter, - NoVerifyParameter + NoVerifyParameter, + ParentFolderIdParameter, + PrivateParameter, + RewardParameter, + SeedPhraseParameter, + TransactionIdParameter, + TxFilePathParameter, + UnsafeDrivePasswordParameter, + WalletFileParameter ] as const; export type ParameterName = typeof AllParameters[number]; @@ -159,6 +161,27 @@ Parameter.declare({ required: true }); +Parameter.declare({ + name: RewardParameter, + aliases: ['-r', '--reward'], + description: `amount of Winston to set as the transaction reward`, + required: true +}); + +Parameter.declare({ + name: LastTxParameter, + aliases: ['-l', '--last-tx'], + description: `the transaction ID of the last transaction sent by this wallet`, + required: true +}); + +Parameter.declare({ + name: TxFilePathParameter, + aliases: ['-x', '--tx-file-path'], + description: `path on the filesystem from which to load the signed transaction data`, + required: true +}); + Parameter.declare({ name: DestinationAddressParameter, aliases: ['-d', '--dest-address'], diff --git a/yarn.lock b/yarn.lock index 1658925f..f985ef5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1000,6 +1000,7 @@ __metadata: "@typescript-eslint/parser": ^4.18.0 ardrive-core-js: 1.0.4 arweave: ^1.10.16 + axios: ^0.21.1 chai: ^4.3.4 commander: ^8.2.0 eslint: ^7.23.0