Skip to content

Commit

Permalink
[fee-payer] Add multi-agent / fee payer support
Browse files Browse the repository at this point in the history
This adds 3 new APIs, prepRawTransaction, signRawTransaction, and
signAndSubmitRawTransaction.  Prep will create a multiagent or fee
payer transaction accordingly, unsigned with information from the
wallet on sequence number and chain id.  Then all parties can sign
the transaction with signRawTransaction, and the last party can
sign with signAndSubmitRawTransaction to sign and send it off as the
sender.
  • Loading branch information
gregnazario committed Oct 30, 2023
1 parent 621a8b0 commit b63f928
Show file tree
Hide file tree
Showing 7 changed files with 353 additions and 92 deletions.
7 changes: 7 additions & 0 deletions .changeset/polite-tigers-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@aptos-labs/wallet-adapter-nextjs-example": minor
"@aptos-labs/wallet-adapter-core": minor
"@aptos-labs/wallet-adapter-react": minor
---

Added support for Multiagent and fee payer transactions
128 changes: 104 additions & 24 deletions apps/nextjs-example/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import dynamic from "next/dynamic";
import Image from "next/image";
import {useAutoConnect} from "../components/AutoConnectProvider";
import {useAlert} from "../components/AlertProvider";
import {AccountInfo, NetworkInfo, WalletInfo} from "@aptos-labs/wallet-adapter-core";
import {
AccountInfo,
NetworkInfo,
WalletInfo
} from "@aptos-labs/wallet-adapter-core";
import {useState} from "react";

const FEE_PAYER_ACCOUNT_KEY = "feePayerAccountPrivateKey";
Expand All @@ -35,37 +39,62 @@ const WalletSelectorAntDesign = dynamic(
);

const aptosClient = (network?: string) => {
if (network === NetworkName.Devnet.toLowerCase()) {
if (isDevnet(network)) {
return DEVNET_CLIENT;
} else if (network === NetworkName.Testnet.toLowerCase()) {
}else if (isTestnet(network)) {
return TESTNET_CLIENT;
} else if (network === NetworkName.Mainnet.toLowerCase()) {
} else if (isLocal(network)) {
return LOCAL_CLIENT;
} else if (isMainnet(network)) {
throw new Error("Please use devnet or testnet for testing");
} else {
throw new Error(`Unknown network: ${network}`);
}
}

const faucet = (network?: string) => {
if (network === NetworkName.Devnet.toLowerCase()) {
if (isDevnet(network)) {
return DEVNET_FAUCET;
} else if (network === NetworkName.Testnet.toLowerCase()) {
} else if (isTestnet(network)) {
return TESTNET_FAUCET;
} else if (network === NetworkName.Mainnet.toLowerCase()) {
} else if (isMainnet(network)) {
throw new Error("Please use devnet or testnet for testing");
} else if (isLocal(network)) {
return LOCAL_FAUCET;
} else {
throw new Error(`Unknown network: ${network}`);
}
}

const DEVNET_CLIENT = new Provider(Network.DEVNET);
const TESTNET_CLIENT = new Provider(Network.TESTNET);
const LOCAL_CLIENT = new Provider(Network.LOCAL);
const DEVNET_FAUCET = new FaucetClient("https://fullnode.devnet.aptoslabs.com", "https://faucet.devnet.aptoslabs.com");
const TESTNET_FAUCET = new FaucetClient("https://fullnode.testnet.aptoslabs.com", "https://faucet.testnet.aptoslabs.com");
const LOCAL_FAUCET = new FaucetClient("http://localhost:8080", "http://localhost:8081");

const isDevnet = (network?: string): boolean => {
let net = network?.toLowerCase();
return net === Network.DEVNET.toLowerCase()
}
const isTestnet = (network?: string): boolean => {
let net = network?.toLowerCase();
return net === Network.TESTNET.toLowerCase()
}
const isMainnet = (network?: string): boolean => {
let net = network?.toLowerCase();
return net === Network.MAINNET.toLowerCase()
}

const isLocal = (network?: string): boolean => {
let net = network?.toLowerCase();
return net === "localhost" || net === "local"
}

const isSendableNetwork = (connected: boolean, network?: string): boolean => {
return connected && (network?.toLowerCase() === NetworkName.Devnet.toLowerCase()
|| network?.toLowerCase() === NetworkName.Testnet.toLowerCase())
return connected && (isDevnet(network)
|| isTestnet(network)
|| isLocal(network))
}

export default function App() {
Expand Down Expand Up @@ -312,11 +341,12 @@ function OptionalFunctionality() {
connected,
account,
network,
signAndSubmitTransaction,
signAndSubmitBCSTransaction,
signTransaction,
signMessageAndVerify,
signMultiAgentTransaction,
prepRawTransaction,
signRawTransaction,
signAndSubmitRawTransaction,
} = useWallet();
let sendable = isSendableNetwork(connected, network?.name)

Expand Down Expand Up @@ -409,7 +439,56 @@ function OptionalFunctionality() {
return feePayerAccount;
}

const onSubmitFeePayer = async () => {
const signAndSubmitFeePayerTransaction = async () => {
if (!account) {
throw new Error("Not connected");
}

let provider = aptosClient(network?.name.toLowerCase());
// Generate an account and fund it
let feePayerAccount = await fundAndSetFeePayer(provider);

const payload: Types.TransactionPayload = {
type: "entry_function_payload",
function: "0x1::aptos_account::transfer",
type_arguments: [],
arguments: [account.address, 1], // 1 is in Octas
};

// Get the information about gas & expiration from the wallet
let rawTxn = await prepRawTransaction({
payload: payload,
feePayer: {
publicKey: feePayerAccount.pubKey().hex(),
address: feePayerAccount.address().hex()
},
});

if (!rawTxn) {
console.log("Failed to prep transaction");
return;
}

// Sign with fee payer
const feePayerAuthenticator = await provider.signMultiTransaction(feePayerAccount, (rawTxn as TxnBuilderTypes.FeePayerRawTransaction));

// Sign and submit with wallet
const response = await signAndSubmitRawTransaction({
rawTransaction: rawTxn,
feePayerAuthenticator: feePayerAuthenticator,
});
if (!response?.hash) {
throw new Error(`No response given ${response}`)
}
await aptosClient(network?.name.toLowerCase()).waitForTransaction(response.hash);
setSuccessAlertHash(response.hash, network?.name);

setSuccessAlertMessage(
JSON.stringify({signAndSubmitTransaction: response ?? "No response"})
);
};

const signFeePayerTransaction = async () => {
if (!account) {
throw new Error("Not connected");
}
Expand Down Expand Up @@ -441,26 +520,25 @@ function OptionalFunctionality() {
const feePayerAuthenticator = await provider.signMultiTransaction(feePayerAccount, rawTxn);

// Sign with user
const userSignature = await signMultiAgentTransaction(rawTxn);
// TODO: Why do we need to check this when the error should fail?
const userSignature = await signRawTransaction(rawTxn);
if (!userSignature) {
return;
}

// TODO: This extra code here needs to be put into the wallet adapter
let userAuthenticator = new TxnBuilderTypes.AccountAuthenticatorEd25519(
new TxnBuilderTypes.Ed25519PublicKey(HexString.ensure(account.publicKey as string).toUint8Array()),
new TxnBuilderTypes.Ed25519Signature(HexString.ensure(userSignature as string).toUint8Array())
new TxnBuilderTypes.Ed25519PublicKey(HexString.ensure(userSignature.publicKey as string).toUint8Array()),
new TxnBuilderTypes.Ed25519Signature(HexString.ensure(userSignature.signature as string).toUint8Array())
);

// Submit it TODO: the wallet possibly should send it instead?
let response: undefined | Types.PendingTransaction = undefined;
response = await provider.submitFeePayerTransaction(rawTxn, userAuthenticator, feePayerAuthenticator);
if (response?.hash === undefined) {
throw new Error(`No response given ${response}`)
}
await aptosClient(network?.name.toLowerCase()).waitForTransaction(response.hash);
setSuccessAlertHash(response.hash, network?.name);
response = await provider.submitFeePayerTransaction(rawTxn, userAuthenticator, feePayerAuthenticator);
if (response?.hash === undefined) {
throw new Error(`No response given ${response}`)
}
await aptosClient(network?.name.toLowerCase()).waitForTransaction(response.hash);
setSuccessAlertHash(response.hash, network?.name);

setSuccessAlertMessage(
JSON.stringify({signAndSubmitTransaction: response ?? "No response"})
Expand All @@ -477,8 +555,10 @@ function OptionalFunctionality() {
<Button color={"blue"} onClick={onSignTransaction} disabled={!sendable} message={"Sign transaction"}/>
<Button color={"blue"} onClick={onSignAndSubmitBCSTransaction} disabled={!sendable}
message={"Sign and submit BCS transaction"}/>
<Button color={"blue"} onClick={onSubmitFeePayer} disabled={!sendable}
message={"Sign and submit fee payer"}/>
<Button color={"blue"} onClick={signFeePayerTransaction} disabled={!sendable}
message={"Sign and submit fee payer by application"}/>
<Button color={"blue"} onClick={signAndSubmitFeePayerTransaction} disabled={!sendable}
message={"Sign and submit fee payer by wallet"}/>
</Col>
</Row>;
}
Expand Down
152 changes: 129 additions & 23 deletions packages/wallet-adapter-core/src/WalletCore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import {HexString, TxnBuilderTypes, Types} from "aptos";
import {
AnyRawTransaction,
HexString,
Network,
Provider,
TxnBuilderTypes,
Types
} from "aptos";
import EventEmitter from "eventemitter3";
import nacl from "tweetnacl";
import { Buffer } from "buffer";
Expand Down Expand Up @@ -29,7 +36,7 @@ import {
Wallet,
WalletInfo,
WalletCoreEvents,
TransactionOptions,
TransactionOptions, RawTransactionRequest, RawTransactionPrepPayload, SignRawTransactionResponse,
} from "./types";
import {
removeLocalStorage,
Expand Down Expand Up @@ -371,27 +378,6 @@ export class WalletCore extends EventEmitter<WalletCoreEvents> {
}
}

async signMultiAgentTransaction(
transaction: TxnBuilderTypes.MultiAgentRawTransaction | TxnBuilderTypes.FeePayerRawTransaction
): Promise<string | null> {
if (this._wallet && !("signMultiAgentTransaction" in this._wallet)) {
throw new WalletNotSupportedMethod(
`Multi agent & Fee payer transactions are not supported by ${this.wallet?.name}`
).message;
}
try {
this.doesWalletExist();
const response = await (this._wallet as any).signMultiAgentTransaction(
transaction
);
return response;
} catch (error: any) {
const errMsg =
typeof error == "object" && "message" in error ? error.message : error;
throw new WalletSignTransactionError(errMsg).message;
}
}

/**
Event for when account has changed on the wallet
@return the new account info
Expand Down Expand Up @@ -501,4 +487,124 @@ export class WalletCore extends EventEmitter<WalletCoreEvents> {
throw new WalletSignMessageAndVerifyError(errMsg).message;
}
}

/**
* Provides a raw transaction for wallets to sign.
*
* This includes building a transaction for multi-agent and fee payer transactions. It pulls the address,
* sequence number, and chain id from the wallet directly.
*
* @param input Raw transaction arguments and fee payer / multiagent signers
*/
async prepRawTransaction(input: RawTransactionPrepPayload): Promise<AnyRawTransaction> {
try {
this.doesWalletExist();
} catch (error: any) {
const errMsg =
typeof error == "object" && "message" in error ? error.message : error;
throw new WalletSignTransactionError(errMsg).message;
}
if (!this._account?.address) {
throw new WalletNotSupportedMethod(`Wallet not providing address of account`).message;
}

const provider = this.getClient();

// If there is a fee payer, then it's a fee payer transaction
// Default expiration time is 60 seconds because there are more actions
const expiration_timestamp_secs = Math.floor(Date.now() / 1000) + (input.options?.expiration_milliseconds ?? 60000);
const additional_signers = input.additionalSigners?.map((value) => {return value.address}) ?? [];

// TODO: Add simulation for gas estimation
if (!input.feePayer && !input.additionalSigners) {
return await provider.generateTransaction(HexString.ensure(this._account.address), input.payload)
} else if (input.feePayer) {
return await provider.generateFeePayerTransaction(this._account.address, input.payload, input.feePayer.address, additional_signers, {
max_gas_amount: input.options?.max_gas_amount?.toString(),
gas_unit_price: input.options?.gas_unit_price?.toString(),
expiration_timestamp_secs: expiration_timestamp_secs.toString()
})
} else {
// TODO: Support multiagent without fee payer
throw new WalletNotSupportedMethod(`Multiagent without fee payer is not supported yet`).message;
}
}

/**
* Signs a raw transaction of any type.
*
* This handles signing for single signer, fee payer, and multi-agent transactions.
* @param rawTransaction any raw transaction for signing
*/
async signRawTransaction(
rawTransaction: AnyRawTransaction
): Promise<SignRawTransactionResponse | null> {
if (this._wallet && !("signRawTransaction" in this._wallet)) {
throw new WalletNotSupportedMethod(
`SignRawTransaction is not supported by ${this.wallet?.name}`
).message;
}
try {
this.doesWalletExist();
const response = await (this._wallet as any).signRawTransaction(
rawTransaction
);
return response;
} catch (error: any) {
const errMsg =
typeof error == "object" && "message" in error ? error.message : error;
throw new WalletSignTransactionError(errMsg).message;
}
}

/**
* Signs a raw transaction of any type and submits it to the blockchain.
*
* Similar to `signRawTransaction` but with submission to the blockchain.
* @param input The raw transaction to be submitted to the blockchain and associated options
*/
async signAndSubmitRawTransaction(input: RawTransactionRequest): Promise<any> {
if (this._wallet && !("signAndSubmitRawTransaction" in this._wallet)) {
throw new WalletNotSupportedMethod(`signAndSubmitRawTransaction not supported by ${this.wallet?.name}`).message;
}
try {
this.doesWalletExist();
return await (this._wallet as any).signAndSubmitRawTransaction(input);
} catch (error: any) {
throw this.convertError(error);
}
}

private getClient(): Provider {
let network = this._network;
if (!network) {
throw new WalletNotSupportedMethod(`Function not supported by ${this.wallet?.name} due to network() not being supported`).message;
}

// TODO: All wallets need to support nodeUrl properly
// Now the fun about estimating a proper URL for the node
if (network.url) {
// We have a URL, let's use it
return new Provider({fullnodeUrl: network.url})
} else if (network.name) {
// We don't have a URL, let's use the default for the network
if (network.name.toLowerCase() === Network.MAINNET.toLowerCase()) {
return new Provider(Network.MAINNET);
} else if (network.name.toLowerCase() === Network.TESTNET.toLowerCase()) {
return new Provider(Network.TESTNET);
} else if (network.name.toLowerCase() === Network.DEVNET.toLowerCase()) {
return new Provider(Network.DEVNET);
} else {
throw new WalletNotSupportedMethod(`Invalid network ${network.name} without node URL for ${this.wallet?.name}`).message;
}
} else {
throw new WalletNotSupportedMethod(`Function not supported by ${this.wallet?.name} due to network() not being supported`).message;
}
}

private convertError(error: any) {
const errMsg =
typeof error == "object" && "message" in error ? error.message : error;
return new WalletSignTransactionError(errMsg).message;
}
}
Loading

0 comments on commit b63f928

Please sign in to comment.