Send and receive payments
Payments Overview
A payment constitutes the transfer of a token from one account to another. On Stellar network a token can be either a Stellar asset or a custom contract token which follows the SEP-41 token standard.
There are two primary ways payments are transacted on Stellar:
- Using Stellar's payment-related operations
- Invoking a function on a token contract.
The approach you should take depends on the use case:
- If you want to make a payment of a Stellar asset between two Stellar accounts, use the payment operations. Transaction fees are cheaper when using them compared to the fees for invoking the asset's token contract directly which is referred to as the Stellar Asset Contract.
- If you want to make a payment of a Stellar asset between a Stellar account and a contract address, or between two contract addresses, then the asset's contract must be used. Stellar's payment-related operations cannot have contract addresses as their source or destination.
- If you want to make a payment of a custom contract token which is not a Stellar asset but follows the SEP-41 token standard, you must use the token's contract. Stellar's payment operations can only be used to transfer Stellar assets.
To learn more about the differences between Stellar assets and contract tokens, see the Tokens overview.
In the following code samples, proper error checking is omitted for brevity. However, you should always validate your results, as there are many ways that requests can fail.
Using Payments Example
This example highlights the approach mentioned for transacting payments using operations and assets and using the Stellar RPC with Stellar Client SDKs to perform all actions needed.
Send a Payment
Lets demonstrate a payment of an asset on Stellar. We will build a transaction with a payment operation to send 10 Lummens from a sender account to a receiver account, sign it as the sender account, and submit it to the network.
- Submitting the transaction to the SDF-maintained public testnet instance of Stellar RPC server.
- When submitting transactions to the RPC server it's possible that you will not receive a response from the server due to network conditions.
- In such a situation it's impossible to determine the status of your transaction.
- Highlights a recommendation to always save a transaction (or transaction encoded in XDR format) in a variable or a database and resubmit it if you don't know its status.
- The transaction in serialized XDR format is idempotent meaning if the transaction has already been successfully applied to the ledger, RPC will simply return the saved result and not attempt to submit the transaction again.
- Only in cases where a transaction's status is unknown (and thus will have a chance of being included into a ledger) will a resubmission to the network occur.
Click to view payment sending code
- JavaScript
- Java
- Go
// send_payment.js
// follow the https://github.com/stellar/js-stellar-sdk?tab=readme-ov-file#installation
import * as StellarSdk from "@stellar/stellar-sdk";
import fs from "fs";
const rpcServer = new StellarSdk.rpc.Server(
"https://soroban-testnet.stellar.org",
);
async function sendPayments() {
const { Keypair } = StellarSdk;
const senderKeyPair = Keypair.random();
const recipientKeyPair = Keypair.random();
let senderAccount;
try {
// Request airdrop for the sender account - this creates, funds and returns the Account object
senderAccount = await rpcServer.requestAirdrop(senderKeyPair.publicKey());
console.log("Sender Account funded with airdrop");
console.log("Sender Account ID:", senderAccount.accountId());
console.log("Sender Sequence number:", senderAccount.sequence.toString());
// Note - this persistence of sender id to a file on file system is only done for demo purposes.
// It shares the created sender account with the payment monitor example which will run next.
// Since this example is using file system it therefore is intended to run only on Node.
// In a real application the stellar js sdk can be used in browser or Node.
fs.writeFileSync("sender_public.key", senderAccount.accountId());
console.log("\n\n");
await rpcServer.requestAirdrop(recipientKeyPair.publicKey());
console.log("Recipient Account funded with airdrop");
console.log("Recipient Account ID:", recipientKeyPair.publicKey());
} catch (err) {
console.error("Airdrop / Account loading failed:", err);
return;
}
// Now call sendPayment with the funded account, in a loop of once every 30 seconds
while (true) {
console.log("\n\nSending payment...");
await sendPayment(
senderKeyPair,
senderAccount,
recipientKeyPair.publicKey(),
);
await new Promise((resolve) => setTimeout(resolve, 30000)); // wait for 30 seconds
}
}
async function sendPayment(sender, senderAccount, recipient) {
// The next step is to parametrize and build the transaction object:
// Using the source account we just loaded we begin to assemble the transaction.
// We set the fee to the base fee, which is 100 stroops (0.00001 XLM).
// We also set the network passphrase to TESTNET.
const transaction = new StellarSdk.TransactionBuilder(senderAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
// We then add a payment operation to the transaction oject.
// This operation will send 10 XLM to the destination account.
// Obs.: Not specifying a explicit source account here means that the
// operation will use the source account of the whole transaction, which we specified above.
.addOperation(
StellarSdk.Operation.payment({
destination: recipient,
asset: StellarSdk.Asset.native(),
amount: "10",
}),
)
// We include an optional memo which oftentimes is used to identify the transaction
// when working with pooled accounts or to facilitate reconciliation.
.addMemo(StellarSdk.Memo.id("1234567890"))
// Finally, we set a timeout for the transaction.
// This means that the transaction will not be valid anymore after 180 seconds.
.setTimeout(180)
.build();
// We sign the transaction with the source account's secret key.
transaction.sign(sender);
// Now we can send the transaction to the network.
// The sendTransaction method immediately returns a reply with the transaction hash
// and the status "PENDING". This means the transaction was received and is being processed.
const sendTransactionResponse = await rpcServer.sendTransaction(transaction);
// Here we check the status of the transaction as there are
// a possible outcomes after sending a transaction that would have
// to be handled accordingly, such as "DUPLICATE" or "TRY_AGAIN_LATER".
if (sendTransactionResponse.status !== "PENDING") {
throw new Error(
`Failed to send transaction, status: ${sendTransactionResponse.status}`,
);
}
console.log(
"Payment Transaction submitted, hash:",
sendTransactionResponse.hash,
);
// Here we poll the transaction status to await for its final result.
// We can use the transaction hash to poll the transaction status later.
const finalStatus = await rpcServer.pollTransaction(
sendTransactionResponse.hash,
);
// The pollTransaction method will return the final status of the transaction
// after the specified number of attempts or when the transaction is finalized.
// We then check the final status of the transaction and handle it accordingly.
switch (finalStatus.status) {
case StellarSdk.rpc.Api.GetTransactionStatus.FAILED:
console.error("Transaction failed, status:", finalStatus.status);
if (finalStatus.resultXdr) {
console.error(
"Transaction Result XDR (decoded):",
JSON.stringify(finalStatus.resultXdr, null, 2),
);
}
throw new Error(`Transaction failed with status: ${finalStatus.status}`);
case StellarSdk.rpc.Api.GetTransactionStatus.NOT_FOUND:
throw new Error(`Transaction failed with status: ${finalStatus.status}`);
case StellarSdk.rpc.Api.GetTransactionStatus.SUCCESS:
console.log("Success! Committed on Ledger:", finalStatus.ledger);
break;
}
}
sendPayments().catch((error) => {
console.error("Error executing sendPayments function:", error);
process.exit(1);
});
// SendPaymentExample.java
// Java 17, Stellar Java SDK (lightsail-network/java-stellar-sdk)
import org.stellar.sdk.Account;
import org.stellar.sdk.AssetTypeNative;
import org.stellar.sdk.KeyPair;
import org.stellar.sdk.MemoId;
import org.stellar.sdk.Network;
import org.stellar.sdk.SorobanServer;
import org.stellar.sdk.Transaction;
import org.stellar.sdk.TransactionBuilder;
import org.stellar.sdk.operations.PaymentOperation;
import org.stellar.sdk.responses.sorobanrpc.GetTransactionResponse;
import org.stellar.sdk.responses.sorobanrpc.GetTransactionResponse.GetTransactionStatus;
import org.stellar.sdk.responses.sorobanrpc.SendTransactionResponse.SendTransactionStatus;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
public class SendPaymentJavaExample {
public static void main(String[] args) throws Exception {
final String RPC_URL = "https://soroban-testnet.stellar.org";
SorobanServer rpc = new SorobanServer(RPC_URL);
// Generate sender/recipient and fund via Friendbot (testnet)
// get the friendbot url from rpc info
var info = rpc.getNetwork();
System.out.println("Friendbot URL: " + info.getFriendbotUrl());
KeyPair sender = KeyPair.random();
KeyPair recipient = KeyPair.random();
friendbotFund(sender.getAccountId(), info.getFriendbotUrl());
friendbotFund(recipient.getAccountId(), info.getFriendbotUrl());
Files.writeString(Paths.get("sender_public.key"),
sender.getAccountId(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
// Load sender sequence using RPC getLedgerEntries
Account source = new Account(sender.getAccountId(), rpc.getAccount(sender.getAccountId()).getSequenceNumber());
// do this in a loop, send payment once every 30 seconds
while (true) {
// Build classic payment: 10 XLM, memo id, 180s timeout
Transaction tx = new TransactionBuilder(source, Network.TESTNET)
.setBaseFee(Transaction.MIN_BASE_FEE)
.addOperation(PaymentOperation.builder().destination(recipient.getAccountId()).asset(new AssetTypeNative())
.amount(new BigDecimal("10")).build())
.addMemo(new MemoId(1234567890L))
.setTimeout(180)
.build();
tx.sign(sender);
// Submit via RPC
var sendResp = rpc.sendTransaction(tx);
if (!SendTransactionStatus.PENDING.equals(sendResp.getStatus())) {
throw new RuntimeException("Failed to send transaction, status: " + sendResp.getStatus());
}
System.out.println("Submitted. Hash: " + sendResp.getHash());
// Poll final status (throws on FAILED/NOT_FOUND)
var finalResp = pollTransaction(rpc, sendResp.getHash());
System.out.println("Success! Committed on ledger: " + finalResp.getLedger());
Thread.sleep(30000L); // wait 30s
}
}
// Fund account with Friendbot (testnet)
static void friendbotFund(String accountId, String friendbotUrl) throws Exception {
OkHttpClient http = new OkHttpClient.Builder().build();
String url = friendbotUrl + "?addr=" + URLEncoder.encode(accountId, StandardCharsets.UTF_8);
Request req = new Request.Builder().url(url).build();
Response res = http.newCall(req).execute();
if (res.code() != 200) {
throw new RuntimeException("Friendbot funding failed: " + res.code() + " " + res.body().string());
}
System.out.println("Funded: " + accountId);
}
// Helper: wrap SorobanServer.pollTransaction and enforce failure handling
static GetTransactionResponse pollTransaction(SorobanServer rpc, String txHash) throws Exception {
GetTransactionResponse resp = rpc.pollTransaction(txHash);
if (GetTransactionStatus.SUCCESS.equals(resp.getStatus())) {
return resp;
}
if (GetTransactionStatus.FAILED.equals(resp.getStatus())) {
if (resp.getResultXdr() != null) {
System.err.println("Failed TX, Result XDR (base64): " + resp.getResultXdr());
}
throw new RuntimeException("Transaction failed.");
}
if (GetTransactionStatus.NOT_FOUND.equals(resp.getStatus())) {
throw new RuntimeException("Transaction not found.");
}
throw new RuntimeException("Unexpected status: " + resp.getStatus());
}
}
// sender.go
package main
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/stellar/go/keypair"
"github.com/stellar/go/network"
"github.com/stellar/go/txnbuild"
sdk "github.com/stellar/stellar-rpc/client"
"github.com/stellar/stellar-rpc/protocol"
)
// friendbotFund funds an account using the Stellar testnet friendbot
func friendbotFund(accountID, friendbotURL string) error {
// Construct the URL with account parameter
fullURL := friendbotURL + "?addr=" + url.QueryEscape(accountID)
// Make HTTP GET request
resp, err := http.Get(fullURL)
if err != nil {
return fmt.Errorf("failed to make friendbot request: %v", err)
}
defer resp.Body.Close()
// Check if response is successful (2xx status code)
if resp.StatusCode/100 != 2 {
return fmt.Errorf("friendbot funding failed: %d", resp.StatusCode)
}
fmt.Printf("Funded: %s\n", accountID)
return nil
}
// check panics if there's an error
func check(err error) {
if err != nil {
panic(err)
}
}
// SignAndSend builds, signs, and submits a transaction with the given operations
func SignAndSend(
ctx context.Context,
client *sdk.Client,
account txnbuild.Account,
signers []*keypair.Full,
operations ...txnbuild.Operation,
) protocol.GetTransactionResponse {
// Build, sign, and submit the transaction
tx, err := txnbuild.NewTransaction(
txnbuild.TransactionParams{
SourceAccount: account,
IncrementSequenceNum: true,
BaseFee: txnbuild.MinBaseFee,
Preconditions: txnbuild.Preconditions{
TimeBounds: txnbuild.NewInfiniteTimeout(),
},
Operations: operations,
},
)
check(err)
for _, signer := range signers {
tx, err = tx.Sign(network.TestNetworkPassphrase, signer)
check(err)
}
txnB64, err := tx.Base64()
check(err)
txSendResp, err := client.SendTransaction(ctx,
protocol.SendTransactionRequest{Transaction: txnB64})
check(err)
for i := range 5 {
txResp, err := client.GetTransaction(ctx,
protocol.GetTransactionRequest{Hash: txSendResp.Hash})
check(err)
switch txResp.Status {
case "NOT_FOUND":
case "SUCCESS":
return txResp
case "FAILED":
panic(fmt.Errorf("transaction failed: %s",
strings.Join(txResp.DiagnosticEventsXDR, "\n")))
}
// Increase delay for each polling request
time.Sleep(time.Duration(i) * time.Second)
}
panic(fmt.Errorf("transaction never found: %s", txSendResp.Hash))
}
// sendPayment creates and submits a payment transaction using the helper function
func sendPayment(ctx context.Context, rpcClient *sdk.Client, sourceAccount txnbuild.Account, signerKP *keypair.Full, destinationAddr string) error {
// Create payment operation
paymentOp := txnbuild.Payment{
Destination: destinationAddr,
Amount: "10",
Asset: txnbuild.NativeAsset{},
}
// Use the helper function to build, sign, and submit
fmt.Printf("Submitting payment of 10 XLM to %s...\n", destinationAddr)
txResp := SignAndSend(ctx, rpcClient, sourceAccount, []*keypair.Full{signerKP}, &paymentOp)
fmt.Printf("Success! Committed on ledger: %d\n", txResp.Ledger)
return nil
}
func main() {
const RPC_URL = "https://soroban-testnet.stellar.org"
rpcClient := sdk.NewClient(RPC_URL, nil)
// Get network info to obtain friendbot URL
ctx := context.Background()
networkInfo, err := rpcClient.GetNetwork(ctx)
if err != nil {
panic(fmt.Sprintf("Failed to get network info: %v", err))
}
fmt.Printf("Friendbot URL: %s\n", networkInfo.FriendbotURL)
// Generate sender and recipient keypairs
sender, err := keypair.Random()
if err != nil {
panic(fmt.Sprintf("Failed to generate sender keypair: %v", err))
}
recipient, err := keypair.Random()
if err != nil {
panic(fmt.Sprintf("Failed to generate recipient keypair: %v", err))
}
// Fund accounts via friendbot
err = friendbotFund(sender.Address(), networkInfo.FriendbotURL)
if err != nil {
panic(fmt.Sprintf("Failed to fund sender: %v", err))
}
err = friendbotFund(recipient.Address(), networkInfo.FriendbotURL)
if err != nil {
panic(fmt.Sprintf("Failed to fund recipient: %v", err))
}
// Save sender public key to file
err = os.WriteFile("sender_public.key", []byte(sender.Address()), 0644)
if err != nil {
panic(fmt.Sprintf("Failed to write sender public key: %v", err))
}
// Load sender account info
senderAccount, err := rpcClient.LoadAccount(ctx, sender.Address())
if err != nil {
panic(fmt.Sprintf("Failed to get sender account: %v", err))
}
// Payment loop - send payment every 30 seconds
for {
err := sendPayment(ctx, rpcClient, senderAccount, sender, recipient.Address())
if err != nil {
panic(fmt.Sprintf("Payment failed: %v", err))
}
fmt.Println("Waiting 30 seconds before next payment...")
time.Sleep(30 * time.Second)
// Reload account to get updated sequence number
senderAccount, err = rpcClient.LoadAccount(ctx, sender.Address())
if err != nil {
panic(fmt.Sprintf("Failed to reload sender account: %v", err))
}
}
}
Monitoring Payments as event stream
Leverage the latest Protocol 23 (Whisk) to capture payment activity as pure events. These events can be efficiently monitored using the RPC server's getEvents
method, which provides server-side filtering and real-time payment detection.
The RPC approach offers several advantages:
- Server-side event topic filtering: Filter for specific events using topic patterns
- Efficient polling: Use cursors and pagination for reliable event processing
- Unified event model: supports CAP-67, all asset movements on the network are emitted as events with standardized topic names to indicate the type of movement (e.g., "transfer" for payments)
- Better error handling: RPC stateless HTTP polling is more resilient than persistent streaming protocols like SSE or WebSockets
Demonstrate near real-time monitoring of transactions from the Stellar network by establishing an asynchronous listener to consume events from RPC and filter them by topic for just our example payments generated by our previous payments script.
Click to view payment monitoring code
- JavaScript
- Java
- Go
// monitor_payment.js
// follow the https://github.com/stellar/js-stellar-sdk?tab=readme-ov-file#installation
import * as StellarSdk from "@stellar/stellar-sdk";
import fs from "fs";
async function monitorPayments() {
// Initialize RPC server for testnet
const rpcServer = new StellarSdk.rpc.Server(
"https://soroban-testnet.stellar.org",
);
// One-time initialization - everything encapsulated here
const monitoredFromAccount = fs
.readFileSync("sender_public.key", "utf-8")
.trim();
// create our payment event topic filter values
const transferTopicFilter =
StellarSdk.xdr.ScVal.scvSymbol("transfer").toXDR("base64");
const fromTopicFilter = StellarSdk.nativeToScVal(monitoredFromAccount, {
type: "address",
}).toXDR("base64");
// Get starting ledger
const latestLedger = await rpcServer.getLatestLedger();
console.log(
`Starting payment monitoring from ledger ${latestLedger.sequence} and from account ${monitoredFromAccount}`,
);
let currentStartLedger = latestLedger.sequence;
let currentCursor;
while (true) {
// Query for payments from our monitored account
const eventsResponse = await rpcServer.getEvents({
startLedger: currentStartLedger,
cursor: currentCursor,
filters: [
{
type: "contract",
topics: [
[
transferTopicFilter,
fromTopicFilter,
"**", // filter will match on any 'to' address and any token(SAC or SEP-41)
],
],
},
],
limit: 10,
});
// Process any events found
console.log(`Found ${eventsResponse.events.length} payment(s):`);
for (const event of eventsResponse.events) {
console.log("\n--- Payment Received ---");
console.log(`Ledger: ${event.ledger}`);
console.log(`Transaction Hash: ${event.txHash}`);
console.log(`Closed At: ${event.ledgerClosedAt}`);
// Decode addresses from topics
try {
const fromAddress = StellarSdk.scValToNative(event.topic[1]);
const toAddress = StellarSdk.scValToNative(event.topic[2]);
console.log(`Transfer: ${fromAddress} → ${toAddress}`);
} catch (error) {
console.log(`From/To: Unable to decode addresses`, error);
continue;
}
// Decode transfer amount from the event data
try {
// Protocol 23+ unified events model per CAP-0067
// https://github.com/stellar/stellar-protocol/blob/master/core/cap-0067.md
// Event value field is scMap with {amount: i128, to_muxed_id: u64|bytes|string} (when muxed info present)
const amount = StellarSdk.scValToNative(event.value)["amount"];
console.log(
`Amount: ${amount.toString()} stroops (${(Number(amount) / 10000000).toFixed(7)} XLM)`,
);
} catch (error) {
console.log(`Failed to decode transfer amount:`, error);
}
// Decode asset from topics[3], this is only present in events from SAC tokens
if (event.topic.length > 3) {
const asset = StellarSdk.scValToNative(event.topic[3]);
console.log(`Asset: ${asset}`);
}
}
// Update cursor to drive next query
currentCursor = eventsResponse.cursor;
currentStartLedger = null;
// Wait 5 seconds before next iteration
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
// Start the application
monitorPayments().catch((error) => {
console.error("Failed during payment monitoring:", error);
});
// MonitorPaymentExample.java
// Java 17, Stellar Java SDK (lightsail-network/java-stellar-sdk)
import org.stellar.sdk.SorobanServer;
import org.stellar.sdk.responses.sorobanrpc.GetLatestLedgerResponse;
import org.stellar.sdk.requests.sorobanrpc.EventFilterType;
import org.stellar.sdk.requests.sorobanrpc.GetEventsRequest;
import org.stellar.sdk.scval.Scv;
import org.stellar.sdk.xdr.SCSymbol;
import org.stellar.sdk.xdr.SCVal;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
public class MonitorPaymentsJavaExample {
public static void main(String[] args) throws Exception {
final String RPC_URL = "https://soroban-testnet.stellar.org";
SorobanServer rpc = new SorobanServer(RPC_URL);
// Load the account we monitor payments FROM (written by send_payment example)
String monitoredFromAccount = Files.readString(Paths.get("sender_public.key"), StandardCharsets.UTF_8).trim();
// Build topic filters using SDK helpers (base64 XDR via toXdrBase64())
String transferTopicFilter = Scv.toSymbol("transfer").toXdrBase64();
String fromTopicFilter = Scv.toAddress(monitoredFromAccount).toXdrBase64();
// Starting point: latest ledger
GetLatestLedgerResponse latest = rpc.getLatestLedger();
Long startLedger = latest.getSequence().longValue();
String cursor = null;
System.out.printf("Starting payment monitoring from ledger %d and from account %s%n", startLedger,
monitoredFromAccount);
while (true) {
// filter will match on any 'to' address and any token(SAC or SEP-41)
GetEventsRequest.EventFilter eventFilter = GetEventsRequest.EventFilter.builder()
.type(EventFilterType.CONTRACT)
.topic(Arrays.asList(transferTopicFilter, fromTopicFilter, "**"))
.build();
GetEventsRequest.PaginationOptions paginationOptions = GetEventsRequest.PaginationOptions.builder()
.limit(10L)
.cursor(cursor)
.build();
GetEventsRequest getEventsRequest = GetEventsRequest.builder()
.startLedger(startLedger)
.filter(eventFilter)
.pagination(paginationOptions)
.build();
var eventsResp = rpc.getEvents(getEventsRequest);
var events = eventsResp.getEvents();
System.out.println("Found " + events.size() + " payment(s):");
for (var ev : events) {
System.out.println("\n--- Payment Received ---");
System.out.println("Ledger: " + ev.getLedger());
System.out.println("Transaction Hash: " + ev.getTransactionHash());
System.out.println("Closed At: " + ev.getLedgerClosedAt());
List<String> topics = ev.getTopic();
if (topics.size() < 3) {
System.out.println("Invalid event, not enough topics.");
continue;
}
try {
String fromAddr = Scv.fromAddress(SCVal.fromXdrBase64(topics.get(1))).toString();
String toAddr = Scv.fromAddress(SCVal.fromXdrBase64(topics.get(2))).toString();
System.out.println("Transfer: " + fromAddr + " → " + toAddr);
} catch (Exception e) {
System.out.println("From/To: Unable to decode addresses: " + e.getMessage());
}
try {
// the event value is a map with key of Symbol for 'amount' to i128 value
Map<SCVal, SCVal> map = Scv.fromMap(SCVal.fromXdrBase64(ev.getValue()));
SCVal amount = map.get(Scv.toSymbol("amount"));
if (amount != null) {
String amountStr = Scv.fromInt128(amount).toString();
BigDecimal raw = new BigDecimal(amountStr);
System.out.printf("Token Amount Transferred: (%.7f)%n", raw.scaleByPowerOfTen(-7));
}
} catch (Exception e) {
System.out.println("Failed to decode transfer amount: " + e.getMessage());
}
try {
if (topics.size() > 3) {
// Decode asset from topics[3], this is only present in events from SAC tokens
System.out.println("Asset: " + new String(Scv.fromString(SCVal.fromXdrBase64(topics.get(3)))));
}
} catch (Exception ex) {
System.out.println("Asset: Unable to decode asset" + ex.getMessage());
}
}
// pagination: update cursor and clear startLedger for subsequent calls
cursor = eventsResp.getCursor();
startLedger = null;
Thread.sleep(5000L);
}
}
}
// monitor.go
package main
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/stellar/go/strkey"
"github.com/stellar/go/xdr"
sdk "github.com/stellar/stellar-rpc/client"
"github.com/stellar/stellar-rpc/protocol"
)
func main() {
const RPC_URL = "https://soroban-testnet.stellar.org"
rpcClient := sdk.NewClient(RPC_URL, nil)
ctx := context.Background()
// Load the account we monitor payments FROM (written by send_payment example)
senderKeyBytes, err := os.ReadFile("sender_public.key")
if err != nil {
panic(fmt.Sprintf("Failed to read sender_public.key: %v", err))
}
monitoredFromAccount := strings.TrimSpace(string(senderKeyBytes))
fmt.Printf("Starting payment monitoring from account: %s\n", monitoredFromAccount)
// Get latest ledger as starting point
latestLedger, err := rpcClient.GetLatestLedger(ctx)
if err != nil {
panic(fmt.Sprintf("Failed to get latest ledger: %v", err))
}
startLedger := latestLedger.Sequence
var cursor string
fmt.Printf("Starting payment monitoring from ledger %d\n", startLedger)
for {
err := monitorPayments(ctx, rpcClient, monitoredFromAccount, &startLedger, &cursor)
if err != nil {
fmt.Printf("Error monitoring payments: %v\n", err)
}
time.Sleep(5 * time.Second)
}
}
func monitorPayments(ctx context.Context, client *sdk.Client, fromAccount string, startLedger *uint32, cursor *string) error {
// Create a simple event filter for contract events
eventFilter := protocol.EventFilter{
EventType: protocol.EventTypeSet{protocol.EventTypeContract: nil},
// We'll filter by topics in the processing logic instead
}
// Create request
request := protocol.GetEventsRequest{
Filters: []protocol.EventFilter{eventFilter},
}
// Set startLedger only on first call
if *startLedger != 0 {
request.StartLedger = *startLedger
}
// Set cursor for pagination
if *cursor != "" {
parsedCursor, err := protocol.ParseCursor(*cursor)
if err != nil {
return fmt.Errorf("failed to parse cursor: %v", err)
}
request.Pagination = &protocol.PaginationOptions{
Cursor: &parsedCursor,
Limit: 10,
}
} else {
request.Pagination = &protocol.PaginationOptions{
Limit: 10,
}
}
// Get events
eventsResp, err := client.GetEvents(ctx, request)
if err != nil {
return fmt.Errorf("failed to get events: %v", err)
}
// Filter events that match our criteria
relevantEvents := []protocol.EventInfo{}
for _, event := range eventsResp.Events {
if isPaymentEvent(event, fromAccount) {
relevantEvents = append(relevantEvents, event)
}
}
fmt.Printf("Found %d payment(s):\n", len(relevantEvents))
// Process relevant events
for _, event := range relevantEvents {
err := processPaymentEvent(event)
if err != nil {
fmt.Printf("Error processing event: %v\n", err)
continue
}
}
// Update pagination
*cursor = eventsResp.Cursor
*startLedger = 0 // Clear start ledger for subsequent calls
return nil
}
func isPaymentEvent(event protocol.EventInfo, fromAccount string) bool {
// Check if this is a transfer event by looking at the first topic
if len(event.TopicXDR) < 2 {
return false
}
// Try to decode the first topic to see if it's "transfer"
if isTransferTopic(event.TopicXDR[0]) && isFromAccount(event.TopicXDR[1], fromAccount) {
return true
}
return false
}
func isTransferTopic(topicXDR string) bool {
xdrBytes, err := base64.StdEncoding.DecodeString(topicXDR)
if err != nil {
return false
}
var scVal xdr.ScVal
err = scVal.UnmarshalBinary(xdrBytes)
if err != nil {
return false
}
return scVal.Type == xdr.ScValTypeScvSymbol &&
scVal.Sym != nil &&
string(*scVal.Sym) == "transfer"
}
func isFromAccount(topicXDR, expectedAccount string) bool {
decodedAddr, err := decodeAddressFromXDR(topicXDR)
return err == nil && decodedAddr == expectedAccount
}
func createTransferTopicFilter() (string, error) {
// Create "transfer" symbol and encode to base64 XDR
symbol := xdr.ScSymbol("transfer")
scVal := xdr.ScVal{
Type: xdr.ScValTypeScvSymbol,
Sym: &symbol,
}
// Use XDR marshaling to base64
xdrBytes, err := scVal.MarshalBinary()
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(xdrBytes), nil
}
func createAddressTopicFilter(accountID string) (string, error) {
// Convert address string to AccountId
accountKey, err := xdr.AddressToAccountId(accountID)
if err != nil {
return "", err
}
// Create SCAddress for account
scAddr := xdr.ScAddress{
Type: xdr.ScAddressTypeScAddressTypeAccount,
AccountId: &accountKey,
}
scVal := xdr.ScVal{
Type: xdr.ScValTypeScvAddress,
Address: &scAddr,
}
// Use XDR marshaling to base64
xdrBytes, err := scVal.MarshalBinary()
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(xdrBytes), nil
}
func processPaymentEvent(event protocol.EventInfo) error {
fmt.Println("\n--- Payment Received ---")
fmt.Printf("Ledger: %d\n", event.Ledger)
fmt.Printf("Transaction Hash: %s\n", event.TransactionHash)
fmt.Printf("Closed At: %s\n", event.LedgerClosedAt)
// Parse topics
if len(event.TopicXDR) < 3 {
fmt.Println("Invalid event, not enough topics.")
return nil
}
// Decode from and to addresses
fromAddr, err := decodeAddressFromXDR(event.TopicXDR[1])
if err != nil {
fmt.Printf("From/To: Unable to decode from address: %v\n", err)
} else {
toAddr, err := decodeAddressFromXDR(event.TopicXDR[2])
if err != nil {
fmt.Printf("From/To: Unable to decode to address: %v\n", err)
} else {
fmt.Printf("Transfer: %s → %s\n", fromAddr, toAddr)
}
}
// Decode amount from event value
if event.ValueXDR != "" {
amount, err := decodeAmountFromXDR(event.ValueXDR)
if err != nil {
fmt.Printf("Failed to decode transfer amount: %v\n", err)
} else {
fmt.Printf("Token Amount Transferred: %.7f\n", float64(amount)/10000000) // Scale by 10^7
}
}
// Decode asset if available
if len(event.TopicXDR) > 3 {
asset, err := decodeAssetFromXDR(event.TopicXDR[3])
if err != nil {
fmt.Printf("Asset: Unable to decode asset: %v\n", err)
} else {
fmt.Printf("Asset: %s\n", asset)
}
}
return nil
}
func decodeAddressFromXDR(base64XDR string) (string, error) {
xdrBytes, err := base64.StdEncoding.DecodeString(base64XDR)
if err != nil {
return "", err
}
var scVal xdr.ScVal
err = scVal.UnmarshalBinary(xdrBytes)
if err != nil {
return "", err
}
if scVal.Type != xdr.ScValTypeScvAddress || scVal.Address == nil {
return "", fmt.Errorf("not an address type")
}
switch scVal.Address.Type {
case xdr.ScAddressTypeScAddressTypeAccount:
if scVal.Address.AccountId == nil {
return "", fmt.Errorf("account ID is nil")
}
return scVal.Address.AccountId.Address(), nil
case xdr.ScAddressTypeScAddressTypeContract:
if scVal.Address.ContractId == nil {
return "", fmt.Errorf("contract ID is nil")
}
return strkey.MustEncode(strkey.VersionByteContract, scVal.Address.ContractId[:]), nil
default:
return "", fmt.Errorf("unknown address type")
}
}
func decodeAmountFromXDR(base64XDR string) (int64, error) {
xdrBytes, err := base64.StdEncoding.DecodeString(base64XDR)
if err != nil {
return 0, err
}
var scVal xdr.ScVal
err = scVal.UnmarshalBinary(xdrBytes)
if err != nil {
return 0, err
}
// The value should be a map containing an "amount" key
if scVal.Type != xdr.ScValTypeScvMap || scVal.Map == nil {
return 0, fmt.Errorf("value is not a map")
}
// Look for the "amount" key in the map
for _, pair := range **scVal.Map {
if pair.Key.Type == xdr.ScValTypeScvSymbol && pair.Key.Sym != nil && string(*pair.Key.Sym) == "amount" {
// Found the amount key, extract the value
if pair.Val.Type == xdr.ScValTypeScvI128 && pair.Val.I128 != nil {
// Convert I128 to int64 (simplified - just use the low part for now)
return int64(pair.Val.I128.Lo), nil
}
}
}
return 0, fmt.Errorf("amount not found in map")
}
func decodeAssetFromXDR(base64XDR string) (string, error) {
xdrBytes, err := base64.StdEncoding.DecodeString(base64XDR)
if err != nil {
return "", err
}
var scVal xdr.ScVal
err = scVal.UnmarshalBinary(xdrBytes)
if err != nil {
return "", err
}
if scVal.Type == xdr.ScValTypeScvString && scVal.Str != nil {
return string(*scVal.Str), nil
}
return "", fmt.Errorf("not a string type")
}
Running Your Payment Pipeline with RPC
- Run the payment submission script in one terminal. Leave it running, it will submit a payment once every 30 seconds.
Click for command to run the payments
- bash
# js
node send_payment.js
# java
# JRE 17 or higher should be installed - https://adoptium.net/temurin/releases/?version=17&package=jdk
# download Stellar Java SDK jar, latest release on maven central
JAR_REPO='https://repo1.maven.org/maven2/network/lightsail/stellar-sdk'
JAR_VERSION="2.0.0" # replace with latest version if needed
JAR_FILE="stellar-sdk-$JAR_VERSION-uber.jar"
curl -fsSL -o "$JAR_FILE" "$JAR_REPO/$JAR_VERSION/$JAR_FILE"
javac -cp "$JAR_FILE" SendPaymentExample.java
java -cp ".:$JAR_FILE" SendPaymentExample
# go
mkdir -p payments_example/sender
cd payments_example
go mod init payments_example
go get github.com/stellar/go@latest
go get github.com/stellar/stellar-rpc@latest
# save sender.go code to sender/sender.go file
go build -o sender sender/sender.go
./sender
- Run the payment monitor script in a separate terminal.
Click for command to run the payments monitor
- bash
# js
node monitor_payments.js
# java
javac -cp "$JAR_FILE" MonitorPaymentExample.java
java -cp ".:$JAR_FILE" MonitorPaymentExample
# go
mkdir -p payments_example/monitor
# save monitor.go code to monitor/monitor.go file
go build -o monitor monitor/monitor.go
./monitor
- Observe the payment events as they're detected and displayed on console.
Summary
-
Payments from transaction operations or contract invocations on Stellar network can be monitored by leveraging the unified events model as of Protocol 23 (Whisk) and the RPC
getEvents
method.- Payments are 'transfer' events: the unified events model ensures whether a payment happened through an operation or contract invocation it will result in the same 'transfer' event being emitted.
- Event Type:
"contract"
denotes payments are an application level event rather than 'system' event. - Transfer Topics Model: an array of four topics, specified in CAP-67 and summarized:
topic[0]
= Event name 'transfer' determines the next 3 topicstopic[1]
= Transfer Sender addresstopic[2]
= Transfer Recipient addresstopic[3]
= Asset identifier (only present for Stellar Assets, through their built-in contracts (SAC))
-
RPC provides precise filtering of Stellar network events and this includes event topics model defined for unified events in CAP-67. You save time by using the RPC server-side filter capabilities to focus on processing only events relative to your application such as payments from a specific account in this example.
- Wildcards: The RPC
getEvents
allows to use "*" to match any value in a topic position - Server-side Filtering: RPC
getEvents
applies the filters and only matching events are returned, reducing bandwidth - Cursor-based Pagination: RPC
getEvents
retrieves event data efficiently with a paging mechanism
- Wildcards: The RPC
Guides in this category:
📄️ Create an account
Learn about creating Stellar accounts, keypairs, funding, and account basics.
📄️ Send and receive payments
Learn to send payments and watch for received payments on the Stellar network.
📄️ Channel accounts
Create channel accounts to submit transactions to the network at a high rate.
📄️ Claimable balances
Split a payment into two parts by creating a claimable balance.
📄️ Clawbacks
Use clawbacks to burn a specific amount of a clawback-enabled asset from a trustline or claimable balance.
📄️ Fee-bump transactions
Use fee-bump transactions to pay for transaction fees on behalf of another account without re-signing the transaction.
📄️ Sponsored reserves
Use sponsored reserves to pay for base reserves on behalf of another account.
📄️ Path payments
Send a payment where the asset received differs from the asset sent.
📄️ Pooled accounts: muxed accounts and memos
Use muxed accounts to differentiate between individual accounts in a pooled account.
📄️ Install and deploy a smart contract with code
Install and deploy a smart contract with code.
📄️ Install WebAssembly (Wasm) bytecode using code
Install the Wasm of the contract using js-stellar-sdk.
📄️ Invoke a contract function in a transaction using SDKs
Use the Stellar SDK to create, simulate, and assemble a transaction.
📄️ simulateTransaction RPC method guide
simulateTransaction examples and tutorials guide.
📄️ Submit a transaction to Stellar RPC using the JavaScript SDK
Use a looping mechanism to submit a transaction to the RPC.