Blog Article
Author
Jeesun Kim and Howard Chen
Publishing date
Remittance
Pay by phone
Vibrant
Global remittances are a huge market opportunity. According to the World Economic Forum (WEF), total global remittances were worth $781 billion in 2021, “and have further risen to $794 billion in 2022.” Further, remittances have a remarkable impact in low and middle-income countries because of their ability to reduce poverty. A World Bank study, for example, found that a 10% increase in remittances reduced poverty levels by 3.5%.
Because of this impact on people in developing countries, reducing the cost of sending money has been on the agenda of WEF since 2015, and the financial technology industry even longer. Now, there is a platform like Wise that has reduced the cost of international money transfer.**
But these solutions still require both sender and recipient to have a bank account. In most cases, sending money abroad is still expensive and cumbersome for those without a bank account. This is where Vibrant comes in, as a mobile wallet that the unbanked can use to facilitate fast and cheap remittances payments. Vibrant users can send Stellar assets like Circle’s fully-reserved dollar digital currency, USDC, ARST, and XLM to their friends and family members within 5 seconds domestically and internationally without the need of a bank account.
Recently we added an improvement to this feature called “Send to Phone” which allows Vibrant users to send USDC to their friends and family who are not Vibrant users (yet) by simply sending USDC via their phone number. It costs zero fees and the recipient can receive the funds within a matter of seconds as long as they register an account on Vibrant with the same phone number.
This is a popular feature among fintech products — Chime’s Pay Anyone and Cash App support a feature similar to this.** However, Vibrant’s “Send to Phone” is a little different.
With Vibrant:
Vibrant enabled this feature by using Stellar’s SEP 30 (multi-party recovery of Stellar accounts). This blog post is going to show you step by step on how we built this feature using SEP 30 with frontend code snippets.
Note: these are highly simplified code samples. They do not not use time bounds, handle a transaction’s expiration, or handle error. If you would like to build one for your project, make sure to check out the SDF’s documentation on how to handle errors gracefully. We’ve also abstracted the backend codes, but the details of what they do are written in the comments above the functions’ declaration to give you an idea of what they do in the back.
If you’re familiar with Stellar, you’ve probably seen a Meridian talk on SEP-30 & the Importance of Key Management and Recovery and the SEP-30 series blog posts, linked below, written by Denelle and Leigh. I highly recommend reading the SEP-30 series first to have a better understanding of SEP-30 before reading this blog post if you haven’t.
The above blog posts illustrate the most well known example of what wallets can do with SEP-30, “account recovery.” But there’s another feature that SEP-30 enables: A Vibrant user can send funds to a non-Vibrant user using just their phone number. This is referred to as the “share” use case.
Unlike “account recovery”, which only assigns one identity to recovery servers. With “share”, we assign two identities: “sender” and “receiver.”
Let’s say User A (sender) in the United States wants to send USDC to her friend, User B (receiver), who lives in Argentina. To achieve this use case, we need to create a temporary account (TA) that both the Sender and Receiver can control (once she registers a Vibrant account with her phone number).
Note: Vibrant developers leverage js-stellar-sdk, a Javascript library for communicating with a Stellar Horizon server, heavily in our project. It makes the development process on Stellar much easier so I highly encourage you to use it if you want to develop on Stellar.
1. Sender generates a new keypair for TA
2. Sender obtains a SEP-10 JWT (JSON Web Token) from Vibrant and Lobstr's recovery server
3. Sender calls SEP-30 POST /accounts/<TA's public key> to register the TA with both servers, providing the following identities:
a. Once the servers verify the SEP-10 JWTs, they store the account address and alternative identities
b. The servers generate a unique random signing key for the account and encrypt the signing secret key and store it
c. The servers respond to the wallet with the signing public key
4. Sender keeps each signing public key to use them later in a transaction as a signer of the TA account in the following step
Once Sender successfully retrieves a signing public key from both Vibrant and Lobstr’s recovery server, she constructs a transaction for creating a TA that includes all the information that Vibrant needs for the TA in order to send funds to the receiver.
1. Sender signs the transaction with the device key and the TA's private key
2. Sender sends the transaction to the backend to get it fee-bumped
3. Sender submits the fee-bumped transaction to Horizon
4. The TA private key is useless after this point and has no future use planned. The device can discard it at this point
import {
Operation,
} from "stellar-sdk";
function createVibrantTempAccount({
senderStellarAddress,
amount, // 3
asset,
phoneNumber,
}: {
senderStellarAddress: string;
amount: string;
asset: Currency; // custom type
phoneNumber: string;
}) {
/**
* Step 1: Sender registers the temp account public key with the two recovery servers
*/
// generateStellarAccount() uses stellar-sdk’s Keypair to
// return temporary account’s public and private key
// make sure to follow the best practice to secure a private key
// https://stellar.github.io/js-stellar-sdk/Keypair.html
const receiverTempAccount = generateStellarAccount();
const USDC = asset;
// recoverysignerSigningKeys includes both recovery servers’ signatures
// that Sender registered earlier by calling
// SEP-30 POST /accounts/<TA's public key> with
// two identities “sender” and “receiver”
let recoverysignerSigningKeys: Signer[] = [{...vibrant recovery server info}, {...lobstr recovery server info}];
// vibrant distribution account is the sponsor of the TA we’re creating
let vibrantDistributionAccountPubkey: string = "GDEZ...";
/**
* Step 2: Sender constructs a transaction (tx) for backend and backend returns a partially signed tx
*/
// signerSetOptions will be used within operations = [] later
const signerSetOptions = recoverysignerSigningKeys.map((signer) =>
Operation.setOptions({
signer: {
ed25519PublicKey: signer.publicKey,
weight: 10, // each recovery server to have weight of 10
},
source: receiverTempAccount.publicKey,
}),
);
const operations = [
// beginSponsoringFutureReserves is needed to cover the initial funding needed for creating a TA, its trustline, and setting signers to the TA
//https://developers.stellar.org/docs/encyclopedia/sponsored-reserves#begin-and-end-sponsorships
Operation.beginSponsoringFutureReserves({
sponsoredId: receiverTempAccount.publicKey,
source: vibrantDistributionAccountPubkey,
}),
Operation.createAccount({
destination: receiverTempAccount.publicKey,
startingBalance: "0",
source: vibrantDistributionAccountPubkey,
}),
Operation.changeTrust({
source: receiverTempAccount.publicKey,
asset: USDC,
}),
...signerSetOptions,
// endSponsoringFutureReserves allows the sponsored account (TA) to accept the sponsorship
// both Begin Sponsoring Future Reserves and End Sponsoring Future Reserves operations must appear in the sponsorship transaction, guaranteeing that both accounts agree to the sponsorship
Operation.endSponsoringFutureReserves({
source: receiverTempAccount.publicKey,
}),
// setOptions to set TA account’s own signature weight and threshold values. // masterWeight to 0 so the TA account can’t do any kind of transaction on its own
// Setting all the thresholds to 20 so that once both recovery signers sign (10 weight + 10 weight = 20), they can access the account
// list of operations with thresholds: https://developers.stellar.org/docs/fundamentals-and-concepts/list-of-operations
Operation.setOptions({
masterWeight: 20,
lowThreshold: 20,
medThreshold: 20,
highThreshold: 20,
source: receiverTempAccount.publicKey,
}),
// payment operation:
// sending 3 USDC from Sender to TA
Operation.payment({
destination: receiverTempAccount.publicKey,
asset: USDC, // any Stellar asset works
amount, // “3”
source: senderStellarAddress,
}),
];
// building a tx with operations we set from the above
// we use TransactionBuilder from js-stellar-sdk in this custom function
const transaction = await buildTransaction(
{
publicKey: senderStellarAddress,
operations,
}
);
/**
* Step 3: Sender signs and submits the transaction
*/
try {
// Sender signs the tx with Sender's device key
// and the TA’s private key
await signWithKeyManager({
transaction,
signer: SENDER_DEVICE_KEY,
});
await signWithKeyManager({
transaction,
signer: TEMP_ACCOUNT_PRIVATE_KEY,
});
}
try {
// call the backend endpoint that validates and signs the tx,
// fee-bumps it, and submits it to Horizon
// TA account’s now created on Stellar network
await appApi.createTempAccountAndSendPayment({
transaction: transaction.toXDR(),
phoneNumber,
});
}
After User B receives a text message about her funds from User A (sender), User B downloads Vibrant and enters her phone number to register a Vibrant account in order to receive the funds. On the UX side, it looks like a normal sign in or sign up flow, but under the hood, we have additional steps.
1. Receiver obtains a SEP-10 JWT token from both servers after registering a Vibrant account with her phone number that was registered with both Vibrant and Lobstr's recovery server by Sender
2. Sender calls a backend endpoint that checks whether Receiver’s phone number matches the phone number from TA payments
3. If there is a match, Receiver queries Horizon to get the balance of a TA to get the payment information to use it in the next step’s transaction to claim her funds
1. Receiver uses the SEP-10 JWT tokens to access the SEP-30 POST /accounts/<TA’s pubkey>/sign/<signing-address> on both recovery signers
2. Receiver adds both signatures to the transaction
3. This transaction is now signed by both recovery signers
Receiver sends the transaction to the backend to get it fee-bumped
Receiver submits the fee-bumped transaction to Horizon
Receiver calls SEP-30 DELETE /accounts/<TA’s address> to both Vibrant’s and Lobstr’s recovery server to delete the TA because it no longer exists
import {
Operation,
} from "stellar-sdk";
// this function is called after Step #1
// querying the TA and turns out Receiver does have a pending TA
const createClaimSendToPhoneTransaction = async ({
receiverStellarAddress,
tempPayment,
jwtTokens, // jwt token received from vibrant and lobstr recovery server
}: {
receiverStellarAddress: string;
tempPayment: Payment; // custom type
jwtTokens: { [key: string]: string };
}) => {
/**
* Step 2: Receiver constructs a transaction and gets it signed
*/
// the payment the sender sent to the TA
const { currency, amount, asset: USDC, tempAccountPubkey } = tempPayment;
// returns a horizon endpoint
const horizon = getHorizon();
// returns an object of vibrant recovery server detail
const localRecoveryServer = getLocalRecoveryServer();
// returns an object of lobstr recovery server detail
const lobstrRecoveryServer = getLobstrRecoveryServer();
const operations = [
Operation.payment({
destination: receiverStellarAddress,
asset: USDC,
amount,
}),
Operation.changeTrust({
asset: USDC,
limit: "0", // means we’re removing the trustline
}),
// accountMerge means we’re merging the TA to Receiver’s account
// in another word, delete the TA account
Operation.accountMerge({
destination: receiverStellarAddress,
}),
];
// building a transaction with operations we set from the above
const transaction = await buildTransaction(
{
publicKey: tempAccountPubkey,
operations,
},
);
try {
// addRecoveryServerSignature calls
// SEP-30 POST /accounts/<TA’s pubkey>/sign/<signing-address>
// signs a transaction that has operations for the TA using the signing key of recovery servers
await addRecoveryServerSignature({
publicKey: tempAccountPubkey,
transaction,
idToken: jwtTokens[localRecoveryServer.id],
recoveryServer: localRecoveryServer,
signerKey: localRecoveryServer.publicKey,
});
await addRecoveryServerSignature({
publicKey: tempAccountPubkey,
transaction,
idToken: jwtTokens[lobstrRecoveryServer.id],
recoveryServer: lobstrRecoveryServer,
signerKey: lobstrRecoveryServer.publicKey,
});
}
/**
* Step 3: Receivers submits the transaction
*/
try {
// fee bump the tx in order to submit the tx
const feeBumpedTransaction = await feeBumpTransaction({ transaction });
// we are submitting the tx that includes a payment operation
// that transfers the funds from the TA to Receiver
// tx has been fee bumped and signed by both recovery signers
await horizon.submitTransaction(feeBumpedTransaction);
// Once it’s submitted, we call removeTempAcctFromRecoverySigner
// which calls SEP-30 DELETE /accounts/<TA’s address> to each
// recovery server to delete the record for the TA since
// it no longer exists on Stellar network
await removeTempAcctFromRecoverySigner({
publicKey: tempAccountPubkey,
idToken: jwtTokens[localRecoveryServer.id],
recoveryServer: localRecoveryServer,
});
await removeAccountFromRecoverySignerWithToken({
publicKey: tempAccountPubkey,
idToken: jwtTokens[lobstrRecoveryServer.id],
recoveryServer: lobstrRecoveryServer,
});
}
};
Once Receiver goes through the step #1 to step #4, she’s able to use the funds that User A sent. She can now keep the funds on Stellar network and use them for booking a trip, purchasing a gift card, simply keeping them in USDC to fight against inflation, or to cash out in her local currency for use in her daily life.
By using SEP-30, Vibrant’s enabled one of the easiest borderless transfers out there.
With this powerful feature enabled via SEP-30, for example, migrant workers in the USA can now send funds immediately to their home country. Vibrant users can also send help to those in a country that is facing a humanitarian crisis by simply entering their phone number. If you’d like to learn more about SEP-30, please visit our SEP-30 documentation.
If you have any questions, please feel free to message me on twitter at @codeandfood.
*Supported cash out countries: US, Mexico, Argentina, Ukraine, Kenya, Colombia, Peru, Guatemala, Dominican Republic, Honduras, Paraguay, Nicaragua, El Salvador, Costa Rica, Uruguay
**Stellar and Vibrant are not affiliated with these companies in any way.