Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Server.getSACBalance for fetching built-in token balance entries #1046

Merged
merged 13 commits into from
Sep 19, 2024
13 changes: 13 additions & 0 deletions src/rpc/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,4 +448,17 @@ export namespace Api {
transactionCount: string; // uint32
ledgerCount: number; // uint32
}

export interface ContractBalanceResponse {
latestLedger: number;
// present only on success, otherwise request malformed or no balance
trustline?: {
Shaptic marked this conversation as resolved.
Show resolved Hide resolved
balance: string;
authorized: boolean;
clawback: boolean;

lastModifiedLedgerSeq?: number;
liveUntilLedgerSeq?: number;
};
}
}
109 changes: 106 additions & 3 deletions src/rpc/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import URI from 'urijs';
import {
Account,
Address,
Asset,
Contract,
FeeBumpTransaction,
Keypair,
StrKey,
Transaction,
nativeToScVal,
scValToNative,
xdr
} from '@stellar/stellar-base';

Expand Down Expand Up @@ -81,7 +85,7 @@ function findCreatedAccountSequenceInTransactionMeta(
?.account()
?.seqNum()
?.toString();

if (sequenceNumber) {
return sequenceNumber;
}
Expand Down Expand Up @@ -878,9 +882,9 @@ export class Server {
}

/**
* Provides an analysis of the recent fee stats for regular and smart
* Provides an analysis of the recent fee stats for regular and smart
* contract operations.
*
*
* @returns {Promise<Api.GetFeeStatsResponse>} the fee stats
* @see https://developers.stellar.org/docs/data/rpc/api-reference/methods/getFeeStats
*/
Expand All @@ -898,4 +902,103 @@ export class Server {
return jsonrpc.postObject(this.serverURL.toString(), 'getVersionInfo');
}

/**
* Returns a contract's balance of a particular token, if any.
sreuland marked this conversation as resolved.
Show resolved Hide resolved
*
* This is a convenience wrapper around {@link Server.getLedgerEntries}.
*
* @param {string} contractId the contract ID (string `C...`) whose
* balance of `token` you want to know
* @param {Asset} token the token or asset (e.g. `USDC:GABC...`) that
* you are querying from the given `contract`.
* @param {string} [networkPassphrase] optionally, the network passphrase to
* which this token applies. If omitted, a request about network
* information will be made (see {@link getNetwork}), since contract IDs
* for assets are specific to a network. You can refer to {@link Networks}
* for a list of built-in passphrases, e.g., `Networks.TESTNET`.
*
* @returns {Promise<Api.ContractBalanceResponse>}, which will contain the
* trustline details if and only if the request returned a valid balance
Shaptic marked this conversation as resolved.
Show resolved Hide resolved
* ledger entry. If it doesn't, the `trustline` field will not exist.
*
* @throws {TypeError} If `contractId` is not a valid contract strkey (C...).
*
* @warning This should not be used for fetching custom token contracts, only
* SACs. Using them with custom tokens is a security concern because they
* can format their balance entries in any way they want, and thus this
* fetch can be very misleading.
*
* @see getLedgerEntries
*/
public async getContractBalance(
contractId: string,
token: Asset,
networkPassphrase?: string
): Promise<Api.ContractBalanceResponse> {
if (!StrKey.isValidContract(contractId)) {
throw new TypeError(`expected contract ID, got ${contractId}`);
}

// Call out to RPC if passphrase isn't provided.
const passphrase: string = networkPassphrase
?? await this.getNetwork().then(n => n.passphrase);

// Turn token into predictable contract ID
const tokenId = token.contractId(passphrase);

// Rust union enum type with "Balance(ScAddress)" structure
const key = xdr.ScVal.scvVec([
nativeToScVal("Balance", { type: "symbol" }),
nativeToScVal(contractId, { type: "address" }),
]);

// Note a quirk here: the contract address in the key is the *token*
// rather than the *holding contract*. This is because each token stores a
// balance entry for each contract, not the other way around (i.e. XLM
// holds a reserve for contract X, rather that contract X having a balance
// of N XLM).
const ledgerKey = xdr.LedgerKey.contractData(
new xdr.LedgerKeyContractData({
contract: new Address(tokenId).toScAddress(),
durability: xdr.ContractDataDurability.persistent(),
key
})
);

const response = await this.getLedgerEntries(ledgerKey);
if (response.entries.length === 0) {
return { latestLedger: response.latestLedger };
}

const {
lastModifiedLedgerSeq,
liveUntilLedgerSeq,
val
} = response.entries[0];

if (val.switch().value !== xdr.LedgerEntryType.contractData().value) {
return { latestLedger: response.latestLedger };
}

// If any field doesn't match *exactly* what we expect, we bail. This
// prevents confusion with "balance-like" entries (e.g., has `amount` but
// isn't a bigint), but still allows "looks like a duck" balance entries.
const entry = scValToNative(val.contractData().val());
if (typeof entry.amount === 'bigint' &&
Shaptic marked this conversation as resolved.
Show resolved Hide resolved
typeof entry.authorized === 'boolean' &&
typeof entry.clawback === 'boolean') {
return {
latestLedger: response.latestLedger,
trustline: {
liveUntilLedgerSeq,
lastModifiedLedgerSeq,
balance: entry.amount.toString(),
authorized: entry.authorized,
clawback: entry.clawback,
}
};
}

return { latestLedger: response.latestLedger };
}
}
174 changes: 174 additions & 0 deletions test/unit/server/soroban/get_contract_balance_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
const { Address, xdr, nativeToScVal, hash } = StellarSdk;
const { Server, AxiosClient, Durability } = StellarSdk.rpc;

describe("Server#getContractBalance", function () {
beforeEach(function () {
this.server = new Server(serverUrl);
this.axiosMock = sinon.mock(AxiosClient);
});

afterEach(function () {
this.axiosMock.verify();
this.axiosMock.restore();
});

const token = StellarSdk.Asset.native();
const contract = "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5";
const contractAddress = new Address(
token.contractId(StellarSdk.Networks.TESTNET),
).toScAddress();

const key = xdr.ScVal.scvVec([
nativeToScVal("Balance", { type: "symbol" }),
nativeToScVal(contract, { type: "address" }),
]);
const val = nativeToScVal(
{
amount: 1_000_000_000_000n,
clawback: false,
authorized: true,
},
{
type: {
amount: ["symbol", "i128"],
clawback: ["symbol", "boolean"],
authorized: ["symbol", "boolean"],
},
},
);

const contractBalanceEntry = xdr.LedgerEntryData.contractData(
new xdr.ContractDataEntry({
ext: new xdr.ExtensionPoint(0),
contract: contractAddress,
durability: xdr.ContractDataDurability.persistent(),
key,
val,
}),
);

// key is just a subset of the entry
const contractBalanceKey = xdr.LedgerKey.contractData(
new xdr.LedgerKeyContractData({
contract: contractBalanceEntry.contractData().contract(),
durability: contractBalanceEntry.contractData().durability(),
key: contractBalanceEntry.contractData().key(),
}),
);

function buildMockResult(that, entry) {
let result = {
latestLedger: 1000,
entries: [
{
lastModifiedLedgerSeq: 1,
liveUntilLedgerSeq: 1000,
key: contractBalanceKey.toXDR("base64"),
xdr: entry.toXDR("base64"),
},
],
};

that.axiosMock
.expects("post")
.withArgs(serverUrl, {
jsonrpc: "2.0",
id: 1,
method: "getLedgerEntries",
params: { keys: [contractBalanceKey.toXDR("base64")] },
})
.returns(
Promise.resolve({
data: { result },
}),
);
}

it("returns the correct trustline", function (done) {
buildMockResult(this, contractBalanceEntry);

this.server
.getContractBalance(contract, token, StellarSdk.Networks.TESTNET)
.then((response) => {
expect(response.latestLedger).to.equal(1000);
expect(response.trustline).to.not.be.undefined;
expect(response.trustline.balance).to.equal("1000000000000");
expect(response.trustline.authorized).to.be.true;
expect(response.trustline.clawback).to.be.false;
done();
})
.catch((err) => done(err));
});

sreuland marked this conversation as resolved.
Show resolved Hide resolved
it("infers the network passphrase", function (done) {
buildMockResult(this, contractBalanceEntry);

this.axiosMock
.expects("post")
.withArgs(serverUrl, {
jsonrpc: "2.0",
id: 1,
method: "getNetwork",
params: null,
})
.returns(
Promise.resolve({
data: {
result: {
passphrase: StellarSdk.Networks.TESTNET,
},
},
}),
);

this.server
.getContractBalance(contract, token)
.then((response) => {
expect(response.latestLedger).to.equal(1000);
expect(response.trustline).to.not.be.undefined;
expect(response.trustline.balance).to.equal("1000000000000");
expect(response.trustline.authorized).to.be.true;
expect(response.trustline.clawback).to.be.false;
done();
})
.catch((err) => done(err));
});

it("errors out when the entry isn't valid", function (done) {
// this doesn't conform to the expected format
const invalidVal = nativeToScVal(
{
amount: 1_000_000, // not an i128
clawback: "false", // not a bool
authorized: true,
},
{
type: {
amount: ["symbol", "u64"],
clawback: ["symbol", "string"],
authorized: ["symbol", "boolean"],
},
},
);
const invalidEntry = xdr.LedgerEntryData.contractData(
new xdr.ContractDataEntry({
ext: new xdr.ExtensionPoint(0),
contract: contractAddress,
durability: xdr.ContractDataDurability.persistent(),
val: invalidVal,
key,
}),
);

buildMockResult(this, invalidEntry);

this.server
.getContractBalance(contract, token, StellarSdk.Networks.TESTNET)
.then((response) => {
expect(response.latestLedger).to.equal(1000);
expect(response.trustline).to.be.undefined;
done();
})
.catch((err) => done(err));
});
});
Loading