> ## Documentation Index
> Fetch the complete documentation index at: https://docs.privy.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Integrating Ground gtokens with Privy

Ground is the cash management layer for stablecoins and lets your app offer RWA yield through gtokens held in user-owned Privy wallets. Users authorize your developer signer once; your Node.js backend then discovers sources, allowlists their wallets, and executes deposits and withdrawals on their behalf.

## Resources

<CardGroup cols={2}>
  <Card title="Ground gtoken docs" icon="arrow-up-right-from-square" href="https://docs.groundtech.co/docs/gtokens/deposit-and-redeem" arrow>
    Official documentation for gtokens, deposits, and redemptions.
  </Card>

  <Card title="Privy developer signer quickstart" icon="rocket" href="/wallets/using-wallets/signers/quickstart" arrow>
    Generate an authorization key and let users add it to their wallet.
  </Card>
</CardGroup>

The example below uses **gUSTB** and **USDC** on **Ethereum Sepolia**, Ground's sandbox network for this walkthrough. gUSTB is the receipt token of a Ground vault that holds Superstate's USTB, a tokenized fund providing exposure to short-duration U.S. Treasury bills.

## Before you start

<Steps>
  <Step title="Create a Ground account and API key">
    Create an account in the [Ground Developer Portal](https://docs.groundtech.co). Once your account is set up, select the sandbox environment and open **API Keys** to generate a key. Copy it when shown and store it securely — it is displayed only once.
  </Step>

  <Step title="Install dependencies and configure environment variables">
    Install the server-side dependencies:

    ```bash theme={"system"}
    npm install @privy-io/node viem
    npm install -D typescript tsx @types/node
    ```

    Configure these environment variables. Keep all secrets on your backend:

    ```bash theme={"system"}
    export GROUND_BASE_URL="https://sandbox.groundtech.co"
    export GROUND_API_KEY="<your-sandbox-api-key>"
    export PRIVY_APP_ID="<your-privy-app-id>"
    export PRIVY_APP_SECRET="<your-privy-app-secret>"
    export PRIVY_AUTHORIZATION_PRIVATE_KEY="<base64-PKCS8-developer-signer-key>"
    export SEPOLIA_RPC_URL="<your-sepolia-rpc-url>"
    ```
  </Step>

  <Step title="Set up a Privy app with a user-owned wallet and your developer signer">
    Create a Privy app and enable user-owned embedded Ethereum wallets. Following the [signer quickstart](/wallets/using-wallets/signers/quickstart), generate a P-256 authorization key and register its public key in a 1-of-1 key quorum. Keep the authorization private key on your backend as base64-encoded PKCS8 without PEM headers, and attach policies permitting Ground's `Admission` signature, USDC approval, and vault calls on Sepolia.

    Have the logged-in user add the signer to their wallet:

    ```tsx theme={"system"}
    addSigners({
      address: userWalletAddress,
      signers: [{signerId: quorumId, policyIds: [groundPolicyId]}]
    });
    ```
  </Step>

  <Step title="Fund the user's wallet">
    Fund the user's wallet with 10 sandbox USDC and some Sepolia ETH for gas. Get test ETH from a [Sepolia faucet](https://cloud.google.com/application/web3/faucet/ethereum/sepolia).
  </Step>
</Steps>

## Discover sources and allowlist the wallet

<Steps>
  <Step title="1. Discover available yield sources">
    Fetch the sources available to your organization. Each catalogue row identifies a source, network, vault address, deposit asset, and contract interface. Fetch each environment's catalogue separately, and discover addresses from the catalogue instead of hardcoding the vault.

    ```ts theme={"system"}
    import {PrivyClient, isEmbeddedWalletLinkedAccount} from '@privy-io/node';
    const privy = new PrivyClient({
      appId: process.env.PRIVY_APP_ID!,
      appSecret: process.env.PRIVY_APP_SECRET!
    });
    // Obtain these from the authenticated request, not a global wallet setting.
    const idToken = '<Privy identity token from request>';
    const selectedWalletId = '<selected user wallet ID from request>';
    // Verify the identity token before accepting a wallet ID from the request.
    const user = await privy.users().get({id_token: idToken});
    // Reject wallets that are not linked to this authenticated user.
    const linkedWallet = user.linked_accounts
      .filter(isEmbeddedWalletLinkedAccount)
      .find((account) => account.id === selectedWalletId && account.chain_type === 'ethereum');
    if (!linkedWallet) throw new Error('Selected wallet does not belong to the authenticated user');
    const wallet = await privy.wallets().get(selectedWalletId);
    // Authorize Privy requests with the developer signer the user added to their wallet.
    const authorizationContext = {
      authorization_private_keys: [process.env.PRIVY_AUTHORIZATION_PRIVATE_KEY!]
    };
    const baseUrl = process.env.GROUND_BASE_URL!;
    const headers = {Authorization: `Bearer ${process.env.GROUND_API_KEY!}`};
    const response = await fetch(`${baseUrl}/v2/gvaults/yield-sources`, {headers});
    if (!response.ok) throw new Error(`Discovery failed: ${response.status}`);
    const {data: sources} = await response.json();
    // Discover addresses from the catalogue instead of hardcoding the vault.
    const source = sources.find(
      (row: {interface: string; chain: string}) =>
        row.interface === 'ground_ustb_v1' && row.chain === 'ethereum_sepolia'
    );
    if (!source) throw new Error('gUSTB is unavailable');
    ```

    <Info>
      Always verify the identity token and confirm the selected wallet belongs to the authenticated user
      before taking any action on it. Fund the selected user-owned wallet before continuing to deposits.
    </Info>
  </Step>

  <Step title="2. Allowlist the wallet">
    Fetch Ground's authorization challenge and use the developer signer to sign it for the user-owned Privy wallet that will hold gUSTB. Submit the signed proof to Ground:

    ```ts {skip-check} theme={"system"}
    const accessUrl = `${baseUrl}/v2/gvaults/${encodeURIComponent(source.id)}/allowlist/${encodeURIComponent(wallet.address)}`;
    // Ground supplies the exact typed data and expiry for this wallet and source.
    const challengeResponse = await fetch(`${accessUrl}/authorization`, {headers});
    if (!challengeResponse.ok) throw new Error(`Challenge failed: ${challengeResponse.status}`);
    const challenge = await challengeResponse.json();
    // Sign as the user wallet through its authorized developer signer; no onchain transaction yet.
    const {signature} = await privy
      .wallets()
      .ethereum()
      .signTypedData(wallet.id, {
        authorization_context: authorizationContext,
        params: {
          typed_data: {
            domain: challenge.typedData.domain,
            types: challenge.typedData.types,
            primary_type: 'Admission',
            message: challenge.typedData.value
          }
        }
      });
    const ownershipProof = {issuedAt: challenge.issuedAt, expiresAt: challenge.expiresAt, signature};
    const submitted = await fetch(accessUrl, {
      method: 'PUT',
      headers: {...headers, 'Content-Type': 'application/json'},
      body: JSON.stringify({ownershipProof})
    });
    if (!submitted.ok) throw new Error(`Allowlisting failed: ${submitted.status}`);
    // Submission can be pending. Continue only once allowed is true; otherwise poll this GET.
    const checked = await fetch(accessUrl, {headers});
    if (!checked.ok) throw new Error(`Access check failed: ${checked.status}`);
    const {allowed, status} = await checked.json();
    ```

    <Warning>
      Enable deposits only when the `GET` returns `allowed: true`. HTTP 202 means the request is pending
      — poll `GET` from your backend. Stop and surface `failed` or `reconciliation_required` rather than
      polling forever. If the signature expires before submission, fetch and sign a new challenge.
    </Warning>
  </Step>
</Steps>

## Transact with the developer signer

Use Node.js 22 or later with TypeScript. The snippets share the user-owned wallet, authorization context, and catalogue source above; put imports at module scope and execute steps in order inside an async function. Download the selected vault's verified implementation ABI from the Ground contracts resource into `ground-ustb-abi.json`.

<Steps>
  <Step title="3. Approve USDC">
    ```ts {skip-check} theme={"system"}
    import {
      createPublicClient,
      http,
      encodeFunctionData,
      erc20Abi,
      parseUnits,
      type Abi,
      type Address,
      type Hex
    } from 'viem';
    import {sepolia} from 'viem/chains';
    import deployedAbi from './ground-ustb-abi.json';
    const publicClient = createPublicClient({
      chain: sepolia,
      transport: http(process.env.SEPOLIA_RPC_URL!)
    });
    const owner = wallet.address as Address;
    const vault = source.contractAddress as Address;
    const asset = source.asset.contractAddress as Address;
    const vaultAbi = deployedAbi as Abi;
    // Convert 10 USDC to integer token units using the catalogue decimals.
    const assets = parseUnits('10', source.asset.decimals);
    // Confirm the vault accepts the asset we are about to approve.
    const onchainAsset = (await publicClient.readContract({
      address: vault,
      abi: vaultAbi,
      functionName: 'asset'
    })) as Address;
    if (onchainAsset.toLowerCase() !== asset.toLowerCase())
      throw new Error('Deposit asset does not match catalogue');
    // Reuse this helper for approval, deposit and redemption from the same user wallet.
    async function send(to: Address, data: Hex) {
      const {hash} = await privy
        .wallets()
        .ethereum()
        .sendTransaction(wallet.id, {
          authorization_context: authorizationContext,
          caip2: 'eip155:11155111',
          params: {transaction: {to, data, value: '0x0'}}
        });
      // Persist hash before waiting; reconcile it before retrying.
      const receipt = await publicClient.waitForTransactionReceipt({hash: hash as Hex});
      if (receipt.status !== 'success') throw new Error('Transaction reverted');
      return receipt;
    }
    // Allow the vault to spend only the deposit amount, rather than an unlimited allowance.
    await send(
      asset,
      encodeFunctionData({abi: erc20Abi, functionName: 'approve', args: [vault, assets]})
    );
    ```
  </Step>

  <Step title="4. Deposit and receive gUSTB">
    After access and approval are confirmed, deposit USDC with the wallet as receiver. The gUSTB shares stay in the user's wallet:

    ```ts {skip-check} theme={"system"}
    // Simulate first to catch current contract reverts before submitting.
    await publicClient.simulateContract({
      account: owner,
      address: vault,
      abi: vaultAbi,
      functionName: 'deposit',
      args: [assets, owner]
    });
    // The second argument is the receiver: gUSTB shares stay in the user wallet.
    const depositReceipt = await send(
      vault,
      encodeFunctionData({
        abi: vaultAbi,
        functionName: 'deposit',
        args: [assets, owner]
      })
    );
    ```

    <Info>
      Use `convertToShares(assets)` for an estimate and confirmed deposit events for actual minted
      shares. The `deposit` method does not enforce a minimum-share output.
    </Info>
  </Step>

  <Step title="5. Check balances">
    Show wallet-held investments, unsettled withdrawals, and claimable USDC separately. Under the USTB escrow implementation, requested shares leave the wallet, so `balanceOf` alone excludes that withdrawal value.

    ```ts {skip-check} theme={"system"}
    // Read every balance at the same block for a consistent snapshot.
    const blockNumber = await publicClient.getBlockNumber();
    const read = (functionName: string, args: readonly unknown[] = [owner]) =>
      publicClient.readContract({address: vault, abi: vaultAbi, functionName, args, blockNumber});

    const walletShares = (await read('balanceOf')) as bigint;
    const requestedShares = (await read('pendingRedeemShares')) as bigint;
    const settledShares = (await read('processedRedeemSharesByOwner')) as bigint;
    const claimableUsdc = (await read('processedRedeemAssetsByOwner')) as bigint;
    const awaitingSettlementShares = requestedShares - settledShares;

    // The underlying calculator rejects zero; zero shares have zero value.
    const investedUsdcEstimate =
      walletShares === 0n ? 0n : await read('convertToAssets', [walletShares]);
    const pendingUsdcEstimate =
      awaitingSettlementShares === 0n ? 0n : await read('convertToAssets', [awaitingSettlementShares]);
    ```

    <Info>
      These reads require the deployed escrow-enabled USTB ABI. `pendingRedeemShares` includes settled,
      unclaimed shares, so subtract `settledShares` before estimating unsettled value. Claimable USDC is
      the actual settled amount; do not also count settled shares in the estimates. Format asset amounts
      using the deposit token's decimals and share amounts using the vault's `decimals()`.
    </Info>
  </Step>

  <Step title="6. Withdraw to the wallet">
    For an immediate exit, check current capacity and redeem. USDC returns to the same user wallet:

    ```ts {skip-check} theme={"system"}
    const shares = walletShares;
    // maxRedeem is immediate exit capacity; larger exits must use the queued flow.
    const max = (await publicClient.readContract({
      address: vault,
      abi: vaultAbi,
      functionName: 'maxRedeem',
      args: [owner]
    })) as bigint;
    if (shares <= 0n || shares > max)
      throw new Error('Choose an amount within immediate capacity, or queue an exit');
    await publicClient.simulateContract({
      account: owner,
      address: vault,
      abi: vaultAbi,
      functionName: 'redeem',
      args: [shares, owner, owner]
    });
    // redeem takes shares, receiver and owner; USDC returns to the same user wallet.
    await send(
      vault,
      encodeFunctionData({abi: vaultAbi, functionName: 'redeem', args: [shares, owner, owner]})
    );
    ```

    For a queued exit, call `requestRedeem(owner, shares)` through the same server `send` helper and save the request ID decoded from the confirmed event. After processing, call `claimRedeem(owner, requestId)` using the verified ABI. Check that request's state or simulate its claim before submitting. Refresh balances after confirmed requests and claims; withdrawal completes when proceeds reach the wallet. See the [Ground deposit and redeem docs](https://docs.groundtech.co/docs/gtokens/deposit-and-redeem).
  </Step>
</Steps>

## Conclusion

Privy makes it straightforward to offer Ground RWA yield from user-owned wallets your users control. For more detail, refer to the [Ground gtoken docs](https://docs.groundtech.co/docs/gtokens/deposit-and-redeem), or reach out in [Slack](https://privy.io/slack).

<Check>
  Your app is now ready to deposit into and redeem Ground gtokens with Privy user-owned wallets!
</Check>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.