Placing dShare Orders with Safe Vaults

This guide shows how to place a dShare order where a safe vault funds the order, signs the permit, and submits the transaction.

How it works

  1. Approve Permit2 from the Safe (once per payment token, per chain).
  2. Create an order request with createPermit and receive the EIP-712 permit.
  3. Sign the permit with the Safe (ERC-1271 signature).
  4. Build the transaction with createPermitTransaction.
  5. Execute the transaction from the Safe.
  6. Poll the order request until it reaches a terminal state.
🚧

Permit2 approval is required

Dinari issues an ERC-2612 permit to any wallet without a Permit2 allowance, and a Safe cannot sign ERC-2612. See Step 3.

sequenceDiagram
    autonumber
    participant S as Safe
    participant P as Partner
    participant D as Dinari
    participant C as Chain

    S->>C: approve(Permit2) on payment token (one-time)
    P->>D: createPermit
    D-->>P: order_request_id, permit
    P->>S: Sign permit (owners)
    S-->>P: ERC-1271 signature
    P->>D: createPermitTransaction
    D-->>P: contract_address, data, value
    P->>S: execTransaction
    S->>C: Submit order
    P->>D: orderRequests.retrieve
    D-->>P: Status

Prerequisites

To place an order, an Entity needs:

  • A valid KYC on the Entity.
  • A valid Account under the Entity.
  • Enough payment-token balance in the Safe to cover the order plus fees.

For this integration you also need:

  • A Dinari API Key ID and API Secret Key from partners.dinari.com. Start in sandbox.

  • A deployed Safe (v1.3.0+) using the default CompatibilityFallbackHandler.
  • Private keys for at least threshold Safe owners, stored server-side.
  • Native gas in the owner EOA that executes Safe transactions.

Step 1: Initialize the clients

import Dinari from "@dinari/api-sdk";
import Safe from "@safe-global/protocol-kit";

const dinariCredentials = await loadDinariCredentialsFromSecretManager();
const walletSecrets = await loadWalletSecretsFromSecretManager();
const config = await loadChainConfig();

const dinari = new Dinari({
  apiKeyID: dinariCredentials.apiKeyID,
  apiSecretKey: dinariCredentials.apiSecretKey,
  environment: "sandbox", // defaults to "production"
});

const safe = await Safe.init({
  provider: config.rpcUrl,
  signer: walletSecrets.ownerPrivateKey, // a Safe owner
  safeAddress: config.safeAddress,
});

if (!(await safe.isSafeDeployed())) {
  throw new Error("Deploy the Safe before signing permits");
}

const safeAddress = await safe.getAddress();

Step 2: Complete KYC and link the Safe (one-time)

KYC requires the user to complete a hosted flow, so it cannot be fully automated server-side.

const entity = await dinari.v2.entities.create({ name: "Jane Doe" });
const kyc = await dinari.v2.entities.kyc.createManagedCheck(entity.id);
console.log("Send the user to:", kyc.embed_url);

const account = await dinari.v2.entities.accounts.create(entity.id);
const accountId = account.id;

const nonceResp = await dinari.v2.accounts.wallet.external.getNonce(accountId, {
  wallet_address: safeAddress,
});

const linkMessage = await safe.signMessage(safe.createMessage(nonceResp.message));

await dinari.v2.accounts.wallet.external.connect(accountId, {
  chain_id: "eip155:421614",
  nonce: nonceResp.nonce,
  signature: linkMessage.encodedSignatures(),
  wallet_address: safeAddress,
});
📘

Link the Safe, not an owner

wallet_address must be the Safe address. Linking an owner EOA attributes orders to a wallet with no funds.

Step 3: Approve Permit2 (one-time, per token)

createpermit picks the permit type from the wallet's on-chain ERC-20 allowance to Permit2, not from the wallet type:

Safe's allowance to Permit2Permit returnedSafe can sign?
NoneERC-2612 PermitNo
PresentPermit2 PermitTransferFromYes

Send the approval as a Safe transaction so the allowance belongs to the Safe:

import { encodeFunctionData, erc20Abi, maxUint256 } from "viem";

const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";

const approveTx = await safe.createTransaction({
  transactions: [
    {
      to: config.paymentTokenAddress,
      value: "0",
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: "approve",
        args: [PERMIT2, maxUint256],
      }),
    },
  ],
});

await safe.executeTransaction(await safe.signTransaction(approveTx));
🚧

Per token, per chain

Each payment token on each chain needs its own approval. If the allowance is revoked or used up, createPermit goes back to returning ERC-2612 permits.

Step 4: Select the stock and order parameters

const stocks = await dinari.v2.marketData.stocks.list();
const stock = stocks.find((s) => s.symbol === "AAPL");
if (!stock) throw new Error("Stock not found");
const stockId = stock.id;
FieldMeaning
stock_idDinari UUID of the dShare (from marketData.stocks.list()).
order_side"BUY" or "SELL".
order_type"MARKET" or "LIMIT".
order_tifTime-in-force, e.g. "DAY".
payment_tokenPayment-token address. Must be the token approved in Step 3.
payment_token_quantityFor a market buy, the notional amount of payment token to spend.
chain_idCAIP-2 chain id, e.g. "eip155:421614".

Market sells and limit orders use asset_quantity instead; limit orders also take limit_price. Fees are added on top of the notional.

Step 5: Create the order permit

const permitResponse = await dinari.v2.accounts.orderRequests.eip155.createPermit(
  accountId,
  {
    chain_id: "eip155:421614",
    order_side: "BUY",
    order_type: "MARKET",
    order_tif: "DAY",
    stock_id: stockId,
    payment_token: config.paymentTokenAddress,
    payment_token_quantity: 10.0,
  },
);

const orderRequestId = permitResponse.order_request_id;
const permit = permitResponse.permit; // { domain, types, primaryType, message }

if (permit.primaryType === "Permit") {
  throw new Error("Got an ERC-2612 permit. Approve Permit2 from the Safe (Step 3).");
}

Check primaryType before signing. Permit means ERC-2612 and will fail; PermitTransferFrom or PermitWitnessTransferFrom means Permit2.

Step 6: Sign the permit with the Safe

let signedPermit = await safe.signMessage(
  safe.createMessage({
    domain: permit.domain,
    types: permit.types,
    primaryType: permit.primaryType,
    message: permit.message,
  }),
);

// For threshold > 1, add each remaining owner's signature:
for (const ownerKey of walletSecrets.additionalOwnerKeys) {
  const ownerSafe = await safe.connect({ signer: ownerKey });
  signedPermit = await ownerSafe.signMessage(signedPermit);
}

const permitSignature = signedPermit.encodedSignatures();
🚧

Use encodedSignatures()

Pass the Safe's encoded signature, not an owner's raw 65-byte signature. The Safe's isValidSignature rejects raw owner signatures.

Permits have a short deadline. If owner approvals take time, create a new permit (Step 5) once signers are ready.

Step 7: Build and send the transaction

const tx = await dinari.v2.accounts.orderRequests.eip155.createPermitTransaction(
  accountId,
  { order_request_id: orderRequestId, permit_signature: permitSignature },
);

const orderTx = await safe.createTransaction({
  transactions: [
    { to: tx.contract_address, data: tx.data, value: tx.value ?? "0" },
  ],
});

const result = await safe.executeTransaction(await safe.signTransaction(orderTx));
console.log("Tx hash:", result.hash);

The owner EOA pays gas; the Safe is msg.sender. Do not send tx.data directly from an owner EOA.

Step 8: Poll order status

const orderRequest = await dinari.v2.accounts.orderRequests.retrieve(
  orderRequestId,
  { account_id: accountId },
);
console.log("Status:", orderRequest.status);

Two status concepts exist:

  • OrderRequestStatus — the request status (pre-brokerage), on the OrderRequest.
  • BrokerageOrderStatus — the resulting Order, via dinari.v2.accounts.orders.retrieve(orderId, { account_id }).

Permit2 and smart contract wallets

ERC-2612 permit() verifies signatures with ecrecover, which only works for EOAs. Permit2 checks whether the signer has code and, if so, calls isValidSignature on it (ERC-1271). A Safe can only pass the second check.

createPermit returns an ERC-2612 permit whenever the wallet has no Permit2 allowance for the payment token. A new Safe has none, so its first order fails. The API accepts the signature either way, because verification happens on-chain:

FlowWhat you see
User SponsoredSafe transaction reverts, usually GS013
Dinari SponsoredNo error returned; the order request expires

🔨

Fix: approve Permit2 from the Safe (Step 3) and check primaryType before signing (Step 5).

Common errors

SymptomLikely CauseFix
primaryType is PermitNo Permit2 allowance for this token/chainApprove Permit2 from the Safe (Step 3)
GS013 on order submissionERC-2612 permit was signedApprove Permit2, create and sign a new permit
Order request expires (Dinari Sponsored)ERC-2612 permit was signedApprove Permit2, create and sign a new permit
isValidSignature revertsSafe not deployed, or non-default fallback handlerDeploy the Safe; use CompatibilityFallbackHandler
Signature rejected with a Permit2 permitRaw owner signature sentSend encodedSignatures()
Transaction reverts as expiredPermit deadline passed during signingCreate a new permit
Wallet linking failsSafe not deployed, or owner EOA linkedDeploy, then link the Safe address
Order reverts or attributed to the wrong wallettx.data sent from an owner EOAExecute through the Safe (Step 7)
Insufficient fundsSafe balance below notional + feesFund the Safe; see Partner Fees

Did this page help you?