For the complete documentation index, see llms.txt. This page is also available as Markdown.
Fiat Withdraw to Bank
This example shows how to initiate a withdraw/payment request from an Anchor that supports outbound payments to a bank. It assumes KYC has already been completed and shared with the Anchor and that the account holds USD that can be sent. See the two KYC guides Add KYC Certificate and Share KYC Attributes for more details on handling KYC.
To run the example on the main network, the following changes are needed.
Change network from test to main
Use the main network Keeta USD Token - keeta_amnkge74xitii5dsobstldatv3irmyimujfjotftx7plaaaseam4bntb7wnna
1
Setup Account and Anchor Client
2
Identify Providers for Transfer
The asset movement client can search the Anchor Resolver metadata to identify providers that can handle the source and destination of the transfer.
3
Construct the Bank Recipient
The Anchor SDK provides specific types for different bank deposits as BankAccountAddressResolved. For US Bank accounts specifically it would use the type UsBankAccountResolved
4
Initiate Transfer
Initiating a transfer with the anchor begins the process to submit the payment. The Anchor will return a specific ID to use as part of the transfer and what account to send the payment too. These are returned as "instructions" that can be used to construct the Keeta block on the network.
5
Send the Payment to the Anchor
Use the Keeta Client SDK to construct a transaction on Keeta that sends the funds to the Anchor to process the outbound payment.
6
Poll the Anchor for Transfer Status
Once the payment has been submitted, the transfer status can be checked periodically until the transfer is complete.
AssetTransfer transfer = await assetMovementClient.InitiateTransfer(
provider,
new AssetTransferRequest(
assetPair,
new AssetTransferSource(keetaSource),
new AssetTransferDestination(Constants.BankAccountUsLocation, bankRecipient),
amountToWithdraw.ToString(CultureInfo.InvariantCulture)),
cancellationToken);
const instruction = transfer.instructions[0];
const anchorAccount = Account.toAccount(instruction.sendToAddress);
const usdTokenAccount = Account.fromPublicKeyString('keeta_any4zllibya6fum3lsoimxmnmeo57nklxlh4c6d6xosfacarfaa3knkiprkmm').assertKeyType(Account.AccountKeyAlgorithm.TOKEN);
// Send the required funds to the anchor account with the provided external identifier instructions
const sendBlockResult = await userClient.send(
anchorAccount,
200n,
usdTokenAccount,
instruction.external
);
AssetTransferStatus transactionResult = await transfer.GetTransferStatus(monitorToken);
string? status = transactionResult.Transaction.TryGetProperty("status", out JsonElement statusElement)
? statusElement.GetString()
: null;
Console.WriteLine($"Status: {status ?? transactionResult.Transaction.GetRawText()}");
#! /usr/bin/env ts-node
/*
* Description: Example of using the Keeta Anchor client to withdraw USD from Keeta Test Network to a US bank account;
* This example assumes that KYC has already been completed and onboarding has been performed.
*/
import * as KeetaAnchor from '@keetanetwork/anchor';
import { Errors, type RecipientResolved } from '@keetanetwork/anchor/services/asset-movement/common.js';
import { debugPrintableObject as DPO, formatDecimals, getFaucetTokens, getTokenDecimals, promptUser } from '../helper.js';
import * as util from 'util';
const Account = KeetaAnchor.KeetaNet.lib.Account;
const DEBUG = false;
const logger = DEBUG ? { logger: console } : {};
const network = 'test';
const KEETA_USD_ASSET = Account.fromPublicKeyString('keeta_any4zllibya6fum3lsoimxmnmeo57nklxlh4c6d6xosfacarfaa3knkiprkmm');
const US_BANK_DESTINATION = { type: 'bank-account', account: { type: 'us' }} as const;
async function main() {
console.log('Keeta Fiat Withdraw Example: Keeta USD => US Bank');
console.log('===================================================\n');
const seed = await promptUser('Enter your Keeta SEED with KYC completed: ');
if (!seed.trim()) {
throw(new Error('Invalid seed'));
}
const account = Account.fromSeed(seed.trim(), 0);
console.log(`Keeta Account: ${account.publicKeyString.get()}`);
console.log(`USD Token: ${KEETA_USD_ASSET.publicKeyString.get()}\n`);
await using userClient = KeetaAnchor.KeetaNet.UserClient.fromNetwork(network, account);
const usdDecimals = await getTokenDecimals(network, KEETA_USD_ASSET);
if (usdDecimals === null) {
throw(new Error('Failed to get USD token decimals'));
}
const baseTokenBalance = await userClient.balance(userClient.baseToken);
if (baseTokenBalance === 0n) {
const faucetRequest = await getFaucetTokens(account, network);
if (!faucetRequest) {
throw(new Error('Failed to get faucet tokens for transaction fees'));
}
}
const currentBalance = await userClient.balance(KEETA_USD_ASSET);
console.log(`Current USD Balance: ${formatDecimals(currentBalance, usdDecimals)} USD`);
if (currentBalance === 0n) {
throw(new Error('You have no USD balance on Keeta Test Network. Deposit USD first (see asset-movement-fiat-deposit.ts).'));
}
const amountInput = await promptUser(`How much USD do you want to withdraw? (max ${formatDecimals(currentBalance, usdDecimals)}): `);
const amountInUsd = parseFloat(amountInput);
if (isNaN(amountInUsd) || amountInUsd <= 0) {
throw(new Error('Invalid amount. Enter a positive number.'));
}
const amountToWithdraw = BigInt(Math.floor(amountInUsd * (10 ** usdDecimals)));
if (amountToWithdraw > currentBalance) {
throw(new Error(`Insufficient balance. You only have ${formatDecimals(currentBalance, usdDecimals)} USD`));
}
const accountNumber = (await promptUser('Enter the US bank account number: ')).trim();
const routingNumber = (await promptUser('Enter the US bank routing number: ')).trim();
const bankName = (await promptUser('Enter the US bank name: ')).trim();
const accountType = (await promptUser('Enter the account type (checking or savings): ')).trim().toLowerCase();
const firstName = (await promptUser('Enter the account holder first name: ')).trim();
const lastName = (await promptUser('Enter the account holder last name: ')).trim();
const addressLine1 = (await promptUser('Enter the US account holder address line 1: ')).trim();
const addressLine2 = (await promptUser('Enter the US account holder address line 2 (optional): ')).trim();
const city = (await promptUser('Enter the account holder city: ')).trim();
const subdivision = (await promptUser('Enter the account holder state (2-letter code): ')).trim();
const postalCode = (await promptUser('Enter the account holder postal code: ')).trim();
if (!routingNumber || !accountNumber || !bankName || !accountType || !firstName || !lastName || !addressLine1 || !city || !subdivision || !postalCode) {
throw(new Error('All bank and account holder fields are required'));
}
const bankRecipient: RecipientResolved = {
type: 'bank-account',
accountType: 'us',
accountNumber,
routingNumber,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
accountTypeDetail: accountType as 'checking' | 'savings',
accountOwner: {
type: 'individual',
firstName,
lastName
},
accountAddress: {
line1: addressLine1,
line2: addressLine2,
city,
subdivision,
postalCode,
country: 'US'
}
} as const;
const assetMovementClient = new KeetaAnchor.AssetMovement.Client(userClient, {
root: userClient.networkAddress,
...logger
});
const keetaSource = {
type: 'chain',
chain: { type: 'keeta', networkId: userClient.network }
} as const;
const assetPair = { from: KEETA_USD_ASSET, to: 'USD' as const };
const providers = await assetMovementClient.getProvidersForTransfer({
asset: assetPair,
from: keetaSource,
to: US_BANK_DESTINATION
});
if (!providers || providers.length === 0) {
throw(new Error('No asset movement providers found for Keeta USD withdrawal to US bank'));
}
const provider = providers[0];
if (!provider) {
throw(new Error('Provider is undefined'));
}
console.log(`\nUsing provider: ${String(provider.providerID)}`);
const proceed = await promptUser('Proceed with the withdrawal? (y/n): ');
if (proceed.trim().toLowerCase() !== 'y') {
throw(new Error('Withdrawal cancelled'));
}
let transfer;
try {
transfer = await provider.initiateTransfer({
account,
asset: assetPair,
from: { location: keetaSource },
to: {
location: US_BANK_DESTINATION,
recipient: bankRecipient
},
value: amountToWithdraw
});
} catch (error) {
if (Errors.KYCShareNeeded.isInstance(error)) {
console.error('KYC attributes must be shared with the provider before a USD withdrawal can be initiated.');
console.error('Complete KYC sharing (see kyc-client-sharekyc.ts), then run this example again.');
return;
}
if (Errors.UserActionNeeded.isInstance(error)) {
console.error('Provider onboarding steps are still required before a USD withdrawal can be initiated.');
console.error('Complete the actions below (see kyc-client-sharekyc.ts), then run this example again.');
console.error(util.inspect(DPO(error.actionsNeeded), { depth: 4, colors: true }));
return;
}
throw(error);
}
console.log(`\nTransfer initiated with ID: ${transfer.transferID}`);
console.log('Instructions:', util.inspect(DPO(transfer.instructions), { depth: 4, colors: true }));
const instruction = transfer.instructions[0];
if (!instruction || instruction.type !== 'KEETA_SEND') {
throw(new Error('Expected KEETA_SEND instruction not found'));
}
const anchorAccount = Account.toAccount(instruction.sendToAddress);
const usdTokenAccount = KEETA_USD_ASSET.assertKeyType(Account.AccountKeyAlgorithm.TOKEN);
if (!instruction.external) {
throw(new Error('Expected external field data in instruction'));
}
console.log('Sending USD to anchor ... please wait ...');
// Send the required funds to the anchor account with the provided external identifier instructions
const sendBlockResult = await userClient.send(
anchorAccount,
amountToWithdraw,
usdTokenAccount,
instruction.external,
{ generateFeeBlock: userClient.config.generateFeeBlock }
);
if (!sendBlockResult.publish || sendBlockResult.from !== 'direct') {
throw(new Error('Failed to send USD to anchor account'));
}
const transferID = transfer.transferID;
console.log('\nMonitoring transfer status ... (checks every 5 seconds; Ctrl+C to stop)');
const startTime = Date.now();
const monitoringInterval = setInterval(async () => {
try {
const transactionResult = await transfer.getTransferStatus();
if (!transactionResult) {
process.stdout.write('.');
return;
}
const txn = transactionResult.transaction;
const elapsed = Math.floor((Date.now() - startTime) / 1000);
console.log(`\n[${elapsed}s] Status: ${txn.status}`);
if (txn.status === 'PROCESSING' || txn.status === 'COMPLETED') {
console.log(`
========================================
WITHDRAWAL PROCESSED SUCCESSFULLY!
========================================
Transfer ID: ${transferID}
Amount: ${formatDecimals(amountToWithdraw, usdDecimals)} USD
From: Keeta Test Network
To: US bank account ending ${accountNumber.slice(-4)}
========================================
`);
const finalBalance = await userClient.balance(KEETA_USD_ASSET);
console.log(`Final USD Balance on Keeta: ${formatDecimals(finalBalance, usdDecimals)} USD`);
clearInterval(monitoringInterval);
process.exit(0);
}
process.stdout.write('.');
} catch (error) {
console.error('\nError monitoring transfer:', error);
}
}, 5000);
process.on('SIGINT', () => {
clearInterval(monitoringInterval);
console.log('\nMonitoring stopped.');
console.log(`Transfer ID: ${transferID}`);
console.log('Check status later with transfer.getTransferStatus().');
process.exit(0);
});
await new Promise(() => {});
}
main().then(function() {
process.exit(0);
}, function(err: unknown) {
console.error(err);
process.exit(1);
});
using System.Globalization;
using System.Numerics;
using System.Text.Json;
using KeetaNet.Anchor;
using KeetaNet.Anchor.Crypto;
using KeetaNet.Examples.Common;
using UserClient = KeetaNet.Examples.Network.UserClient;
namespace KeetaNet.Examples.Anchor;
/// <summary>
/// Port of <c>src/anchor/asset-movement-fiat-withdraw-to-bank.ts</c>.
/// Assumes KYC is complete and provider onboarding has already been performed.
/// </summary>
public sealed class AssetMovementFiatWithdrawToBankExample : IKeetaExample
{
private const string Network = "test";
private static readonly HashSet<string> UsStateCodes = new(StringComparer.OrdinalIgnoreCase)
{
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY",
"LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH",
"OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
};
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public string Id => "anchor/asset-movement-fiat-withdraw-to-bank";
public string Description =>
"Withdraw USD from Keeta Test Network to a US bank account";
public async Task<int> Run(string[] args, CancellationToken cancellationToken = default)
{
Console.WriteLine("Keeta Fiat Withdraw Example: Keeta USD => US Bank");
Console.WriteLine("===================================================");
Console.WriteLine();
using var runtime = WasmRuntime.Load();
string seed = Helper.ReadLine("Enter your Keeta SEED with KYC completed: ").Trim();
if (seed.Length == 0)
{
throw new InvalidOperationException("Invalid seed");
}
using Account userAccount = runtime.Accounts.FromSeed(seed, 0, "ecdsa_secp256k1");
Console.WriteLine($"Keeta Account: {userAccount.PublicKeyString}");
Console.WriteLine($"USD Token: {Constants.KeetaUsdAsset}");
Console.WriteLine();
UserClient userClient = UserClient.FromNetwork(Network, userAccount);
using NodeClient nodeClient = runtime.CreateNodeClient(Constants.NodeApi);
using Account usdToken = runtime.Accounts.FromPublicKeyString(Constants.KeetaUsdAsset);
int? usdDecimals = await Helper.GetTokenDecimals(nodeClient, usdToken, cancellationToken);
if (usdDecimals is null)
{
throw new InvalidOperationException("Failed to get USD token decimals");
}
using Account baseToken = runtime.Accounts.FromPublicKeyString(userClient.BaseToken);
BigInteger baseTokenBalance = await nodeClient.GetAccountBalance(userAccount, baseToken, cancellationToken);
if (baseTokenBalance == BigInteger.Zero)
{
if (!await Helper.GetFaucetTokens(runtime, userAccount, Network, cancellationToken))
{
throw new InvalidOperationException("Failed to get faucet tokens for transaction fees");
}
}
BigInteger currentBalance = await nodeClient.GetAccountBalance(userAccount, usdToken, cancellationToken);
Console.WriteLine($"Current USD Balance: {Helper.FormatDecimals(currentBalance, usdDecimals.Value)} USD");
if (currentBalance == BigInteger.Zero)
{
throw new InvalidOperationException(
"You have no USD balance on Keeta Test Network. Deposit USD first (see asset-movement-fiat-deposit-from-bank).");
}
string amountInput = Helper.ReadLine(
$"How much USD do you want to withdraw? (max {Helper.FormatDecimals(currentBalance, usdDecimals.Value)}): ").Trim();
if (!decimal.TryParse(amountInput, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal amountInUsd)
|| amountInUsd <= 0)
{
throw new InvalidOperationException("Invalid amount. Enter a positive number.");
}
decimal multiplier = (decimal)Math.Pow(10, usdDecimals.Value);
BigInteger amountToWithdraw = new BigInteger(decimal.Truncate(amountInUsd * multiplier));
if (amountToWithdraw > currentBalance)
{
throw new InvalidOperationException(
$"Insufficient balance. You only have {Helper.FormatDecimals(currentBalance, usdDecimals.Value)} USD");
}
string accountNumber = Helper.ReadLine("Enter the US bank account number: ").Trim();
string routingNumber = Helper.ReadLine("Enter the US bank routing number: ").Trim();
string bankName = Helper.ReadLine("Enter the US bank name: ").Trim();
string accountType = Helper.ReadLine("Enter the account type (checking or savings): ").Trim().ToLowerInvariant();
string firstName = Helper.ReadLine("Enter the account holder first name: ").Trim();
string lastName = Helper.ReadLine("Enter the account holder last name: ").Trim();
string addressLine1 = Helper.ReadLine("Enter the US account holder address line 1: ").Trim();
string addressLine2 = Helper.ReadLine("Enter the US account holder address line 2 (optional): ").Trim();
string city = Helper.ReadLine("Enter the account holder city: ").Trim();
string subdivision = Helper.ReadLine("Enter the account holder state (2-letter code): ").Trim();
string postalCode = Helper.ReadLine("Enter the account holder postal code: ").Trim();
if (routingNumber.Length == 0 || accountNumber.Length == 0 || bankName.Length == 0 || accountType.Length == 0
|| firstName.Length == 0 || lastName.Length == 0 || addressLine1.Length == 0 || city.Length == 0
|| subdivision.Length == 0 || postalCode.Length == 0)
{
throw new InvalidOperationException("All bank and account holder fields are required");
}
ValidateUsBankDetails(routingNumber, accountType, subdivision);
subdivision = subdivision.ToUpperInvariant();
var bankRecipient = new
{
type = "bank-account",
accountType = "us",
accountNumber,
routingNumber,
bankName,
accountTypeDetail = accountType,
accountOwner = new
{
type = "individual",
firstName,
lastName,
},
accountAddress = new
{
line1 = addressLine1,
line2 = addressLine2,
city,
subdivision,
postalCode,
country = "US",
},
};
using AssetMovementClient assetMovementClient = runtime.CreateAssetMovementClient(
Constants.NodeApi,
userClient.NetworkAddress,
userAccount);
string keetaSource = $"chain:keeta:{userClient.Network}";
AssetOrPair assetPair = AssetOrPair.Pair(Constants.KeetaUsdAsset, "USD");
IReadOnlyList<AssetProvider> providers = await assetMovementClient.GetProvidersForTransfer(
new AssetProviderSearch(
Asset: assetPair,
From: keetaSource,
To: Constants.BankAccountUsLocation),
cancellationToken);
if (providers.Count == 0)
{
throw new InvalidOperationException("No asset movement providers found for Keeta USD withdrawal to US bank");
}
AssetProvider provider = providers[0];
Console.WriteLine($"\nUsing provider: {provider.Id}");
string proceed = Helper.ReadLine("Proceed with the withdrawal? (y/n): ").Trim();
if (!string.Equals(proceed, "y", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Withdrawal cancelled");
}
AssetTransfer transfer;
try
{
transfer = await assetMovementClient.InitiateTransfer(
provider,
new AssetTransferRequest(
assetPair,
new AssetTransferSource(keetaSource),
new AssetTransferDestination(Constants.BankAccountUsLocation, bankRecipient),
amountToWithdraw.ToString(CultureInfo.InvariantCulture)),
cancellationToken);
}
catch (KeetaBlockerException refusal)
{
switch (refusal.Blocker)
{
case AssetKycShareNeededBlocker:
Console.Error.WriteLine("KYC attributes must be shared with the provider before a USD withdrawal can be initiated.");
Console.Error.WriteLine("Complete KYC sharing (see anchor/kyc-client-sharekyc), then run this example again.");
return 0;
case AssetUserActionNeededBlocker userActionNeeded:
Console.Error.WriteLine("Provider onboarding steps are still required before a USD withdrawal can be initiated.");
Console.Error.WriteLine("Complete the actions below (see anchor/kyc-client-sharekyc), then run this example again.");
Console.Error.WriteLine(JsonSerializer.Serialize(userActionNeeded.ActionsNeeded, JsonOptions));
return 0;
default:
throw;
}
}
catch (KeetaException error) when (error.Code == "SERVICE")
{
Console.Error.WriteLine("The provider rejected the withdrawal request.");
Console.Error.WriteLine(
"Check your bank details: routing number must be 9 digits, state must be a valid US code (e.g. NY, CA), and account type must be checking or savings.");
Console.Error.WriteLine(error.Message);
return 1;
}
Console.WriteLine($"\nTransfer initiated with ID: {transfer.Id}");
Console.WriteLine(JsonSerializer.Serialize(transfer.InstructionChoices, JsonOptions));
JsonElement instruction = transfer.InstructionChoices[0];
if (instruction.GetProperty("type").GetString() != "KEETA_SEND")
{
throw new InvalidOperationException("Expected KEETA_SEND instruction not found");
}
string anchorAccount = instruction.GetProperty("sendToAddress").GetString()
?? throw new InvalidOperationException("Expected sendToAddress in instruction");
if (!instruction.TryGetProperty("external", out JsonElement externalElement))
{
throw new InvalidOperationException("Expected external field data in instruction");
}
string external = externalElement.ValueKind == JsonValueKind.String
? externalElement.GetString()!
: externalElement.GetRawText();
Console.WriteLine("Sending USD to anchor ... please wait ...");
using Account sendTo = runtime.Accounts.FromPublicKeyString(anchorAccount);
await userClient.Send(runtime, sendTo, amountToWithdraw, usdToken, external, cancellationToken);
Console.WriteLine("\nMonitoring transfer status ... (checks every 5 seconds; Ctrl+C to stop)");
using var stopSignal = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
stopSignal.Cancel();
};
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stopSignal.Token);
CancellationToken monitorToken = linked.Token;
DateTimeOffset startTime = DateTimeOffset.UtcNow;
while (!monitorToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(5), monitorToken);
AssetTransferStatus transactionResult = await transfer.GetTransferStatus(monitorToken);
string? status = transactionResult.Transaction.TryGetProperty("status", out JsonElement statusElement)
? statusElement.GetString()
: null;
int elapsed = (int)(DateTimeOffset.UtcNow - startTime).TotalSeconds;
Console.WriteLine($"\n[{elapsed}s] Status: {status ?? transactionResult.Transaction.GetRawText()}");
if (string.Equals(status, "PROCESSING", StringComparison.Ordinal)
|| string.Equals(status, "COMPLETED", StringComparison.Ordinal)
|| string.Equals(status, "COMPLETE", StringComparison.Ordinal))
{
string accountEnding = accountNumber.Length >= 4
? accountNumber[^4..]
: accountNumber;
Console.WriteLine($"""
========================================
WITHDRAWAL PROCESSED SUCCESSFULLY!
========================================
Transfer ID: {transfer.Id}
Amount: {Helper.FormatDecimals(amountToWithdraw, usdDecimals.Value)} USD
From: Keeta Test Network
To: US bank account ending {accountEnding}
========================================
""");
BigInteger finalBalance = await nodeClient.GetAccountBalance(userAccount, usdToken, monitorToken);
Console.WriteLine($"Final USD Balance on Keeta: {Helper.FormatDecimals(finalBalance, usdDecimals.Value)} USD");
return 0;
}
Console.Write('.');
}
catch (OperationCanceledException) when (monitorToken.IsCancellationRequested)
{
break;
}
catch (Exception error)
{
Console.Error.WriteLine($"\nError monitoring transfer: {error}");
}
}
Console.WriteLine("\nMonitoring stopped.");
Console.WriteLine($"Transfer ID: {transfer.Id}");
return 0;
}
private static void ValidateUsBankDetails(string routingNumber, string accountType, string subdivision)
{
if (routingNumber.Length != 9 || !routingNumber.All(char.IsDigit))
{
throw new InvalidOperationException("US bank routing number must be exactly 9 digits.");
}
if (accountType is not ("checking" or "savings"))
{
throw new InvalidOperationException("Account type must be checking or savings.");
}
if (!UsStateCodes.Contains(subdivision))
{
throw new InvalidOperationException("State must be a valid 2-letter US code (e.g. NY, CA).");
}
}
}