3

How can I programmatically send a transaction request to a Gnosis Safe wallet so it appears as a pending request for others to approve?

I assume this can only be done as a signed request from an existing approver.

I'm currently reviewing https://docs.gnosis-safe.io/tutorials/tutorial_tx_service_initiate_sign but need a few examples.

4 Answers 4

3

This is how I am doing it in a React app.

I am connecting to Gnosis safe using their Safe Service. Use the correct url based on the network your Gnosis Safe is created on.

const txServiceMainnet = 'https://safe-transaction.gnosis.io'
const txServiceGoerli = 'https://safe-transaction.goerli.gnosis.io'
const txServiceRinkeby = 'https://safe-transaction.rinkeby.gnosis.io'

Import these

import { ethers } from 'ethers'
import { parseUnits } from 'ethers/lib/utils'
import { SafeTransactionDataPartial } from '@gnosis.pm/safe-core-sdk-types'
import SafeServiceClient from '@gnosis.pm/safe-service-client'
import EthersAdapter from '@gnosis.pm/safe-ethers-lib'
import Safe from '@gnosis.pm/safe-core-sdk'

I have put the rest of the following in a function that is called on a button click.

Get signer from Metamask. This will be one of the owners of the Gnosis Safe that can initiate transactions. You can use other ways to get signer if you like

const provider = new ethers.providers.Web3Provider(window.ethereum)
const signer = provider.getSigner()

These are some info you'll need for transaction. I have these as arguments of this function.

const connectedWallet = '0xabcd' // the address of the connected metamask wallet

const safeAddress = '0xefgh' // your gnosis safe address

const recipientAddress = '0xijkl' // the receiver's wallet address

const amount = '0.1' // sending 0.1 ethers

You'll have to create an adapter for every provider/signer to interact with Gnosis Safe. Create EthAdapter using the Metamask signer and then use it to connect to Safe Service.

const ethAdapter = new EthersAdapter({ ethers, signer })
const safeService = new SafeServiceClient({ txServiceUrl: txServiceRinkeby, ethAdapter })

Now you can interact with your safe. Fetch the safe info using your Gnosis safe address. This will give you info about your safe, the owners, etc. You can log this to see if you have a successful connection with your safe.

const safeInfo = await safeService.getSafeInfo(safeAddress)

Create the transaction object. Recipient address would be the wallet address that will receive the ethers and amount is in string ('1' means 1 eth, '0.1' means 0.1 eth)

const transaction: SafeTransactionDataPartial = {
    to: recipientAddress,
    value: parseUnits(amount).toString(),
    data: '0x',
    nonce: safeInfo.nonce,
}

Create a transaction

const safeSdk = await Safe.create({ ethAdapter, safeAddress })
const safeTransaction = await safeSdk.createTransaction({
    safeTransactionData: ethersTransferTransaction,
})
const safeTransactionHash = await safeSdk.getTransactionHash(safeTransaction)

Sign the transaction. This should make your metamask pop up for a signature approval.

const ownerSignature = await safeSdk.signTransactionHash(safeTransactionHash)

Finally, propose the signed transaction on Gnosis Safe. After this you should see a pending request on the Gnosis Safe waiting for approvals

const result = await safeService.proposeTransaction({
    safeAddress: safeAddress,
    safeTransactionData: safeTransaction.data,
    safeTxHash: safeTransactionHash,
    senderAddress: connectedWallet,
    senderSignature: ownerSignature.data,
})

I hope this will be of help.

2

Not gonna lie, the docs are not fantastic!

follow this gist. This deploys a safe on goerli and and calls the mintTo() function on a NFT contract.

At step 7, the script proposes a transaction to the safeService

2

new API URLs are

ARBITRUM_ONE: "https://safe-transaction-arbitrum.safe.global",
AURORA_MAINNET: "https://safe-transaction-aurora.safe.global",
AVALANCHE_C_CHAIN: "https://safe-transaction-avalanche.safe.global",
BINANCE_SMART_CHAIN_MAINNET: "https://safe-transaction-bsc.safe.global",
ENERGY_WEB_CHAIN: "https://safe-transaction-ewc.safe.global",
GOERLI: "https://safe-transaction-goerli.safe.global",
MAINNET: "https://safe-transaction-mainnet.safe.global",
POLYGON: "https://safe-transaction-polygon.safe.global",
EVMOS: "https://gateway.safe.evmos.org",
OPTIMISM: "https://safe-transaction-optimism.safe.global",
ENERGY_WEB_VOLTA_TESTNET: "https://safe-transaction-volta.safe.global",
GNOSIS: "https://safe-transaction-gnosis-chain.safe.global",
FANTOM: "https://safe-txservice.fantom.network",
BOBA_NETWORK: "https://safe-transaction.mainnet.boba.network",

I think the rest should work

1
  • would be great if you could link to where this config originally is, in new transaction services for other networks are deployed.
    – MShakeG
    Commented Nov 7, 2023 at 13:03
0

If you're looking to send a transaction request to a Safe wallet programmatically using the newer @safe-global packages, here's how you can do it. The example below demonstrates the use of a SafeManager class, which encapsulates all the necessary interactions with a Gnosis Safe.

First, ensure you've installed the necessary packages:

npm install @safe-global/protocol-kit @safe-global/api-kit ethers

Here's a basic implementation of the SafeManager class:

import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import Safe, { EthersAdapter } from "@safe-global/protocol-kit";
import SafeApiKit from "@safe-global/api-kit";
import { MetaTransactionData, SafeSignature, SafeTransaction, SafeTransactionDataPartial } from "@safe-global/safe-core-sdk-types";

// ensure you're using ethers v5 as ethers v6 is not currently supported by @safe-global
// you don't have to use hardhat ethers if you're not in a hardhat project, instead import ethers directly
import { ethers } from "hardhat";

// chain specific config:
enum SupportedChainId {
  ETHEREUM_MAINNET = 1,
  ARBITRUM_MAINNET = 42161,
  POLYGON_MAINNET = 137
}

const SAFE_TRANSACTION_SERVICES: Partial<Record<SupportedChainId, string>> = {
  [SupportedChainId.ETHEREUM_MAINNET]: "https://safe-transaction-mainnet.safe.global",
  [SupportedChainId.ARBITRUM_MAINNET]: "https://safe-transaction-arbitrum.safe.global",
  [SupportedChainId.POLYGON_MAINNET]: "https://safe-transaction-polygon.safe.global"
}

// the following 2 gas configs are only relevant if you plan on using the gas estimation logic
const GAS_REFUND_FRACTION: Record<SupportedChainId, number> = {
  [SupportedChainId.ETHEREUM_MAINNET]: 0.3,
  [SupportedChainId.ARBITRUM_MAINNET]: 0.3,
  [SupportedChainId.POLYGON_MAINNET]: 0.3,
};

const MAX_GAS_PER_TRANSACTION: Record<SupportedChainId, number> = {
  [SupportedChainId.ETHEREUM_MAINNET]: 10_000_000,
  [SupportedChainId.ARBITRUM_MAINNET]: 10_000_000,
  [SupportedChainId.POLYGON_MAINNET]: 10_000_000,
};

export class SafeManager {
  private ethAdapter: EthersAdapter;
  private safeService?: SafeApiKit;
  private safe?: Safe;
  private signer: SignerWithAddress;
  private gasRefundFraction: number | undefined;
  private maxGasPerTx: number | undefined;

  constructor(signer: SignerWithAddress) {
    this.signer = signer;
    this.ethAdapter = new EthersAdapter({
      ethers,
      signerOrProvider: this.signer,
    });
  }

  canProposeTransaction(): boolean {
    return !!this.safeService;
  }

  async initializeSafe(safeAddress: string): Promise<void> {
    if (!this.ethAdapter) {
      throw new Error('EthersAdapter is not initialized');
    }
    this.safe = await Safe.create({
      ethAdapter: this.ethAdapter,
      safeAddress,
    });

    // NOTE: in a hardhat fork getChainId will fetch the chainId for the forked network
    const chainId = (await this.ethAdapter.getChainId()) as SupportedChainId;

    this.gasRefundFraction = GAS_REFUND_FRACTION[chainId];
    this.maxGasPerTx = MAX_GAS_PER_TRANSACTION[chainId];

    const txServiceUrl = SAFE_TRANSACTION_SERVICES[chainId];
    if (txServiceUrl) {
      this.safeService = new SafeApiKit({
        txServiceUrl,
        ethAdapter: this.ethAdapter,
      });
    } else {
      console.warn(`Safe transaction service does not exist for chain ${chainId}`);
    }
  }

  // you can choose to disable gas estimation(and overestimation if you like)
  async estimateGas(safeTransactionData: SafeTransactionDataPartial | MetaTransactionData[]): Promise<number> {
    if (!this.safe) {
      throw new Error('Safe is not initialized');
    }

    const safeAddress = await this.safe.getAddress();

    function overestimateGasLimit(Goriginal: number, maxRefundFraction: number): number {
      const excess = Goriginal * (maxRefundFraction / (1 - maxRefundFraction));
      return Math.floor(Goriginal + excess);
    }

    // Check if safeTransactionData is an array of MetaTransactionData
    if (Array.isArray(safeTransactionData)) {
      // If so, we'll store the total gas estimate here
      let totalGasEstimate = 0;

      for (const tx of safeTransactionData) {
        // Extract "to", "value", and "data" from each transaction
        const {
          to,
          value,
          data
        } = tx;

        // Create the transaction object for each tx
        const transaction = {
          to: to,
          value: value,
          data: data,
          from: safeAddress
        };

        // Estimate the gas for each transaction
        const gasEstimate = (await ethers.provider.estimateGas(transaction)).toNumber();

        console.log("gasEstimate:", gasEstimate);

        // Check for maxGasPerTx for each transaction if needed
        if (this.maxGasPerTx && gasEstimate > this.maxGasPerTx) {
          throw new Error(`gasEstimate for a transaction exceeds maxGasPerTx: ${gasEstimate}`);
        }

        // since we're potentially doing many txs we don't want to go above the max tx gas limit so we use a lower gasRefundFraction
        const gasRefundFraction = 0.1;
        const overestimatedTotalGas = overestimateGasLimit(gasEstimate, gasRefundFraction);

        console.log("overestimatedTotalGas:", overestimatedTotalGas);

        // Add the individual gas estimate to the total
        totalGasEstimate += overestimatedTotalGas;
      }

      console.log(totalGasEstimate);

      return totalGasEstimate;

    } else {

      const {
        to,
        value,
        data,
      } = safeTransactionData;

      // Create the transaction object
      const transaction = {
        to: to,
        value: value,
        data: data,
        from: safeAddress
      };

      // Estimate the gas using the impersonated signer
      const gasEstimate = (await ethers.provider.estimateGas(transaction)).toNumber();

      if (this.maxGasPerTx && gasEstimate > this.maxGasPerTx) {
        throw new Error("gasEstimate exceeds maxGasPerTx");
      }

      const gasRefundFraction = this.gasRefundFraction ?? 0.1;
      // TODO: consider capping overestimatedGas at this.maxGasPerTx
      const overestimatedGas = overestimateGasLimit(gasEstimate, gasRefundFraction);
      return overestimatedGas;
    }
  }

  async createSafeTransaction(safeTransactionData: SafeTransactionDataPartial | MetaTransactionData[]): Promise<SafeTransaction> {
    if (!this.safe) {
      throw new Error('Safe is not initialized');
    }
    const safeTransaction = await this.safe.createTransaction({ safeTransactionData: safeTransactionData });

    const estimatedGas = await this.estimateGas(safeTransactionData);
    safeTransaction.data.safeTxGas = estimatedGas.toString();

    return safeTransaction;
  }

  async createEnableModuleTx(module: string): Promise<SafeTransaction> {
    if (!this.safe) {
      throw new Error('Safe is not initialized');
    }
    return this.safe.createEnableModuleTx(module);
  }

  async signTransaction(safeTransaction: SafeTransaction): Promise<{
    safeTxHash: string,
    safeSignature: SafeSignature
  }> {
    if (!this.safe) {
      throw new Error('Safe is not initialized');
    }
    const safeTxHash = await this.safe.getTransactionHash(safeTransaction);
    const safeSignature = await this.safe.signTransactionHash(safeTxHash);

    console.log({
      safeTxHash,
      safeSignature,
      data: safeTransaction.data,
    });

    return {
      safeTxHash,
      safeSignature: safeSignature,
    }
  }

  async proposeTransaction(
    safeTransactionData: SafeTransaction,
    safeSignature: SafeSignature,
  ): Promise<void> {

    if (!this.safe) {
      throw new Error('Safe or SafeService is not initialized');
    }

    if (!this.safeService) {
      console.warn("Cannot propose tx since there is no Gnosis Safe transaction service for this chain");
      return;
    }

    const safeTxHash = await this.safe.getTransactionHash(safeTransactionData);
    const senderAddress = await this.signer.getAddress();

    await this.safeService.proposeTransaction({
      safeAddress: await this.safe.getAddress(),
      safeTransactionData: safeTransactionData.data,
      safeTxHash,
      senderAddress,
      senderSignature: safeSignature.data,
    });
  }
}

// Usage example:
// const safe = new SafeManager(signer);
// await safe.initializeSafe(safeAddress);
// const safeTransactionData: SafeTransactionDataPartial = {
//   to: "<0xTO>", // account to call "data" on
//   value: "0",
//   data: "0x",
//   operation: OperationType.Call,
// };
// const safeTx = await safe.createSafeTransaction(safeTransactionData);
// const {safeSignature} = await safe.signTransaction(safeTx);
// await safe.proposeTransaction(safeTx, safeSignature);

The SafeManager class provides a high-level abstraction for the following operations:

  • Instantiate a SafeManager with a given signer(that should be an owner of the Safe which is initialized in the next step)
  • Initializing the Safe instance with a Safe address.
  • Creating a SafeTransaction with partial transaction data.
  • Signing the transaction to generate a signature.
  • Proposing the transaction to the Gnosis Safe transaction service.

This class leverages the @safe-global/protocol-kit and @safe-global/api-kit packages, which are the updated versions of the older @gnosis.pm libraries. It's designed to be a drop-in replacement that simplifies the developer experience when working with Gnosis Safe.

Remember to handle exceptions and errors appropriately in your production code, especially around transaction signing and proposal, as these operations can fail for various reasons, such as insufficient funds or invalid nonce values.

The usage example at the end demonstrates how you would use the SafeManager class to create, sign, and propose a transaction to the Gnosis Safe.

Feel free to extend the SafeManager class to include additional functionality or to fit the specific needs of your application.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.