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