Let's say I create an account foo.near
on NEAR with a single full access public key ed25519:publiccc
which is matched to a private key ed25519:secrettt
and stored locally in the .near-credentials/mainnet/foo.near.json
file.
I can now send myself 1 NEAR using near send foo.near foo.near 1
to verify the keys work. If I modify the private key in that keypair at all, the send fails. Good.
Then I replace the public key in that keypair with a nonsense string like ed25519:giggle
.
I can still send myself 1 NEAR using the same command. Why?
It appears the public key is totally irrelevant in this keypair. You can use a nonsense string or a public key that's supposed to have limited access only and the private key gives full access to transfer funds.
Good question! I had to do some digging. So let's start with what the CLI is doing behind the scenes. The code is found here:
exports.sendMoney = async function (options) {
await checkCredentials(options.sender, options.networkId, options.keyStore);
console.log(`Sending ${options.amount} NEAR to ${options.receiver} from ${options.sender}`);
const near = await connect(options);
const account = await near.account(options.sender);
const result = await account.sendMoney(options.receiver, utils.format.parseNearAmount(options.amount));
inspectResponse.prettyPrintResponse(result, options);
};
Essentially it connects to the network, creates an account object, and then calls the sendMoney
method for that account object which is through near-api-js
. The way it's instantiated is through near.account()
which behind the scenes does the following (found here):
async account(accountId: string): Promise<Account> {
const account = new Account(this.connection, accountId);
return account;
}
This is just calling the class' constructor passing in the connection and account ID. Up until this point, we have no reference to the private or public key other than the connection which has a keystore indicating WHERE the keypair will be found. Ok moving on.
After the account class is initialized, the sendMoney
method is called and the only action is a simple transfer with the amount. The code for this method can be seen here.
async sendMoney(receiverId: string, amount: BN): Promise<FinalExecutionOutcome> {
return this.signAndSendTransaction({
receiverId,
actions: [transfer(amount)]
});
}
You'll notice that it calls the signAndSendTransaction
method that is part of the Account Class (found here).
async signAndSendTransaction({ receiverId, actions, returnError }: SignAndSendTransactionOptions): Promise<FinalExecutionOutcome> {
let txHash, signedTx;
// TODO: TX_NONCE (different constants for different uses of exponentialBackoff?)
const result = await exponentialBackoff(TX_NONCE_RETRY_WAIT, TX_NONCE_RETRY_NUMBER, TX_NONCE_RETRY_WAIT_BACKOFF, async () => {
[txHash, signedTx] = await this.signTransaction(receiverId, actions);
const publicKey = signedTx.transaction.publicKey;
try {
return await this.connection.provider.sendTransaction(signedTx);
}
...
...
Out of this method, the important thing is this.signTransaction
whose method is found here:
protected async signTransaction(receiverId: string, actions: Action[]): Promise<[Uint8Array, SignedTransaction]> {
const accessKeyInfo = await this.findAccessKey(receiverId, actions);
if (!accessKeyInfo) {
throw new TypedError(`Can not sign transactions for account ${this.accountId} on network ${this.connection.networkId}, no matching key pair exists for this account`, 'KeyNotFound');
}
const { accessKey } = accessKeyInfo;
const block = await this.connection.provider.block({ finality: 'final' });
const blockHash = block.header.hash;
const nonce = accessKey.nonce.add(new BN(1));
return await signTransaction(
receiverId, nonce, actions, baseDecode(blockHash), this.connection.signer, this.accountId, this.connection.networkId
);
}
In this method, there's a sneaky this.findAccessKey
being done whose code can be found here.
async findAccessKey(receiverId: string, actions: Action[]): Promise<{ publicKey: PublicKey; accessKey: AccessKeyView }> {
// TODO: Find matching access key based on transaction (i.e. receiverId and actions)
const publicKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId);
if (!publicKey) {
throw new TypedError(`no matching key pair found in ${this.connection.signer}`, 'PublicKeyNotFound');
}
const cachedAccessKey = this.accessKeyByPublicKeyCache[publicKey.toString()];
if (cachedAccessKey !== undefined) {
return { publicKey, accessKey: cachedAccessKey };
}
try {
const rawAccessKey = await this.connection.provider.query<AccessKeyViewRaw>({
request_type: 'view_access_key',
account_id: this.accountId,
public_key: publicKey.toString(),
finality: 'optimistic'
});
// store nonce as BN to preserve precision on big number
const accessKey = {
...rawAccessKey,
nonce: new BN(rawAccessKey.nonce),
};
// this function can be called multiple times and retrieve the same access key
// this checks to see if the access key was already retrieved and cached while
// the above network call was in flight. To keep nonce values in line, we return
// the cached access key.
if (this.accessKeyByPublicKeyCache[publicKey.toString()]) {
return { publicKey, accessKey: this.accessKeyByPublicKeyCache[publicKey.toString()] };
}
this.accessKeyByPublicKeyCache[publicKey.toString()] = accessKey;
return { publicKey, accessKey };
} catch (e) {
if (e.type == 'AccessKeyDoesNotExist') {
return null;
}
throw e;
}
}
If you take a look at this code, you'll notice a really important line:
const publicKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId);
What is this returning? The short answer is that if you're using the NEAR-CLI, the connection.signer
comes from UnencryptedFileSystemKeyStore
since your keys are stored in .near-credentials
. Now let's look at this getPublicKey
method found here:
async getPublicKey(accountId?: string, networkId?: string): Promise<PublicKey> {
const keyPair = await this.keyStore.getKey(networkId, accountId);
if (keyPair === null) {
return null;
}
return keyPair.getPublicKey();
}
It does this.keyStore.getKey
where the keyStore
is the UnencryptedFileSystemKeyStore
I mentioned earlier. The getKey
method is then found here:
async getKey(networkId: string, accountId: string): Promise<KeyPair> {
// Find key / account id.
if (!await exists(this.getKeyFilePath(networkId, accountId))) {
return null;
}
const accountKeyPair = await readKeyFile(this.getKeyFilePath(networkId, accountId));
return accountKeyPair[1];
}
In this function, the final culprit is the readKeyFile
where it returns the key pair (both public and private). This can be found here:
export async function readKeyFile(filename: string): Promise<[string, KeyPair]> {
const accountInfo = await loadJsonFile(filename);
// The private key might be in private_key or secret_key field.
let privateKey = accountInfo.private_key;
if (!privateKey && accountInfo.secret_key) {
privateKey = accountInfo.secret_key;
}
return [accountInfo.account_id, KeyPair.fromString(privateKey)];
}
Notice that it returns a KeyPair that is generated from the private key. This means that no matter what the public key is, it will overwrite it. I did a quick test where I console logged the accountInfo
and it indeed returned the incorrect public key that I had set manually but when it generates the KeyPair from the private key, that information is overwritten.
TLDR:
When the your account signs a transaction, the public key is re-generated based on the private key. It doesn't matter what public key you have in your .near-credentials
since it is overwritten when the KeyPair is created based on the private key.