Skip to content

Commit

Permalink
[signing] Allow for signing with external signing platforms
Browse files Browse the repository at this point in the history
Currently, the SDK only supports signing from the Aptos SDK via
the Account object.  This provides the ability to send a
signingMessage to any other system for signatures.  Additionally,
adds a little mock example for separate from SDK signing.
  • Loading branch information
gregnazario committed Nov 26, 2023
1 parent 49e4818 commit 4f0bea9
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to the Aptos TypeScript SDK will be captured in this file. T

- Respect `API_KEY` option in `clientConfig` when making indexer and/or fullnode queries
- [`Added`] Added `waitForIndexer` function to wait for indexer to sync up with full node. All indexer query functions now accepts a new optional param `minimumLedgerVersion` to wait for indexer to sync up with the target processor.
- Add `getSigningMessage` to allow users to sign transactions with external signers and other use cases

Breaking:
- Changes ANS date usage to consistently use epoch timestamps represented in milliseconds.
Expand Down
199 changes: 199 additions & 0 deletions examples/typescript/external_signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/* eslint-disable no-console */

/**
* This example shows an example of how one might send transactions elsewhere to be signed outside the SDK.
*/

import {
Account,
AccountAddress,
AccountAuthenticator,
AccountAuthenticatorEd25519,
Aptos,
AptosConfig,
Deserializer,
Ed25519PrivateKey,
Ed25519PublicKey,
Ed25519Signature,
Network,
NetworkToNetworkName,
RawTransaction,
Serializer,
} from "@aptos-labs/ts-sdk";
import nacl from "tweetnacl";

const APTOS_COIN = "0x1::aptos_coin::AptosCoin";
const COIN_STORE = "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>";
const COLD_INITIAL_BALANCE = 100_000_000;
const HOT_INITIAL_BALANCE = 100;
const TRANSFER_AMOUNT = 100;

// Default to devnet, but allow for overriding
const APTOS_NETWORK: Network = NetworkToNetworkName[process.env.APTOS_NETWORK] || Network.DEVNET;

const balance = async (aptos: Aptos, account: Account, name: string): Promise<number> => {
type Coin = { coin: { value: string } };
const resource = await aptos.getAccountResource<Coin>({
accountAddress: account.accountAddress,
resourceType: COIN_STORE,
});
const amount = Number(resource.coin.value);

console.log(`${name}'s balance is: ${amount}`);
return amount;
};

/**
* Provides a mock "Cold wallet" that's signed externally from the SDK
*/
class ExternalSigner {
private account: Account;

private aptos: Aptos;

public name: string;

public initialBalance: number;

public isSetup: boolean;

private extractedPrivateKey: nacl.SignKeyPair;

constructor(name: string, initialBalance: number) {
const config = new AptosConfig({ network: APTOS_NETWORK });
this.aptos = new Aptos(config);
this.account = Account.generate();
this.name = name;
this.initialBalance = initialBalance;
this.isSetup = false;
this.extractedPrivateKey = nacl.sign.keyPair.fromSeed(
this.account.privateKey.toUint8Array().slice(0, Ed25519PrivateKey.LENGTH),
);
}

address(): AccountAddress {
return this.account.accountAddress;
}

/**
* Setup the account making sure it has funds and exists
*/
async setup() {
if (this.isSetup) {
throw new Error(`Tried to double setup ${this.name}`);
}

console.log(`${this.name}'s address is: ${this.account.accountAddress}`);

const fundTxn = await this.aptos.fundAccount({
accountAddress: this.account.accountAddress,
amount: this.initialBalance,
});
console.log(`${this.name}'s fund transaction: `, fundTxn);
this.isSetup = true;
}

async balance(): Promise<number> {
return balance(this.aptos, this.account, this.name);
}

/**
* Pretends to sign from a cold wallet
* @param encodedTransaction an already encoded signing message
*/
sign(encodedTransaction: Uint8Array): Uint8Array {
// Sending the full transaction as BCS encoded, allows for full text viewing of the transaction on the signer.
// However, this is not required, and the signer could just send the signing message.
const deserializer = new Deserializer(encodedTransaction);
const rawTransaction = RawTransaction.deserialize(deserializer);

// Some changes to make it signable, this would need more logic for fee payer or additional signers
// TODO: Make BCS handle any object type?
const transaction = { rawTransaction };
const signingMessage = this.aptos.getSigningMessage({ transaction });

// Pretend that it's an external signer that only knows bytes using a raw crypto library
const signature = nacl.sign.detached(signingMessage, this.extractedPrivateKey.secretKey);

// Construct the authenticator with the public key for the submission
const authenticator = new AccountAuthenticatorEd25519(
this.account.publicKey as Ed25519PublicKey,
new Ed25519Signature(signature),
);

const serializer = new Serializer();
authenticator.serialize(serializer);
return serializer.toUint8Array();
}
}

const example = async () => {
console.log("This example will pretend that hot is on a separate server, and never access information from it");

// Setup the client
const config = new AptosConfig({ network: APTOS_NETWORK });
const aptos = new Aptos(config);

// Create two accounts
const cold = new ExternalSigner("Cold", COLD_INITIAL_BALANCE);
const hot = Account.generate();
await aptos.fundAccount({ accountAddress: hot.accountAddress, amount: HOT_INITIAL_BALANCE });

console.log("\n=== Funding accounts ===\n");
await cold.setup();

// Show the balances
console.log("\n=== Balances ===\n");
const coldBalance = await cold.balance();
const hotBalance = await balance(aptos, hot, "Hot");

if (coldBalance !== COLD_INITIAL_BALANCE) throw new Error("Cold's balance is incorrect");
if (hotBalance !== HOT_INITIAL_BALANCE) throw new Error("Hot's balance is incorrect");

// Transfer between users
const singleSignerTransaction = await aptos.build.transaction({
sender: cold.address(),
data: {
function: "0x1::coin::transfer",
typeArguments: [APTOS_COIN],
functionArguments: [hot.accountAddress, TRANSFER_AMOUNT],
},
});

// Send the transaction to external signer to sign
const serializer = new Serializer();
singleSignerTransaction.rawTransaction.serialize(serializer);
const rawTransactionBytes = serializer.toUint8Array();

// We're going to pretend that the network call is just an external function call
console.log("\n=== Signing ===\n");
const authenticatorBytes = cold.sign(rawTransactionBytes);
const deserializer = new Deserializer(authenticatorBytes);
const authenticator = AccountAuthenticator.deserialize(deserializer);

console.log(`Retrieved authenticator: ${JSON.stringify(authenticator)}`);

// Combine the transaction and send
console.log("\n=== Transfer transaction ===\n");
const committedTxn = await aptos.submit.transaction({
transaction: singleSignerTransaction,
senderAuthenticator: authenticator,
});

await aptos.waitForTransaction({ transactionHash: committedTxn.hash });
console.log(`Committed transaction: ${committedTxn.hash}`);

console.log("\n=== Balances after transfer ===\n");
const newColdBalance = await cold.balance();
const newHotBalance = await balance(aptos, hot, "Hot");

// Hot should have the transfer amount
if (newHotBalance !== TRANSFER_AMOUNT + HOT_INITIAL_BALANCE)
throw new Error("Hot's balance after transfer is incorrect");

// Cold should have the remainder minus gas
if (newColdBalance >= COLD_INITIAL_BALANCE - TRANSFER_AMOUNT)
throw new Error("Cold's balance after transfer is incorrect");
};

example();
Loading

0 comments on commit 4f0bea9

Please sign in to comment.