diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea800ac1..514599abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ A breaking change will get clearly marked in this log. ## Unreleased +### Added +- `rpc.Server` now has a `getSACBalance` helper which lets you fetch the balance of a built-in Stellar Asset Contract token held by a contract ([#1046](https://github.com/stellar/js-stellar-sdk/pull/1046)): + +```typescript +export interface BalanceResponse { + latestLedger: number; + /** present only on success, otherwise request malformed or no balance */ + balanceEntry?: { + /** a 64-bit integer */ + amount: string; + authorized: boolean; + clawback: boolean; + + lastModifiedLedgerSeq?: number; + liveUntilLedgerSeq?: number; + }; +} +``` + ## [v12.3.0](https://github.com/stellar/js-stellar-sdk/compare/v12.2.0...v12.3.0) diff --git a/src/rpc/api.ts b/src/rpc/api.ts index d1e3addf4..8d65781c4 100644 --- a/src/rpc/api.ts +++ b/src/rpc/api.ts @@ -411,7 +411,7 @@ export namespace Api { transactionData: string; }; - /** State Difference information */ + /** State difference information */ stateChanges?: RawLedgerEntryChange[]; } @@ -448,4 +448,18 @@ export namespace Api { transactionCount: string; // uint32 ledgerCount: number; // uint32 } + + export interface BalanceResponse { + latestLedger: number; + /** present only on success, otherwise request malformed or no balance */ + balanceEntry?: { + /** a 64-bit integer */ + amount: string; + authorized: boolean; + clawback: boolean; + + lastModifiedLedgerSeq?: number; + liveUntilLedgerSeq?: number; + }; + } } diff --git a/src/rpc/server.ts b/src/rpc/server.ts index 3a0c32045..458c0782b 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -4,10 +4,14 @@ import URI from 'urijs'; import { Account, Address, + Asset, Contract, FeeBumpTransaction, Keypair, + StrKey, Transaction, + nativeToScVal, + scValToNative, xdr } from '@stellar/stellar-base'; @@ -897,4 +901,110 @@ export class Server { public async getVersionInfo(): Promise { return jsonrpc.postObject(this.serverURL.toString(), 'getVersionInfo'); } + + /** + * Returns a contract's balance of a particular SAC asset, if any. + * + * This is a convenience wrapper around {@link Server.getLedgerEntries}. + * + * @param {string} contractId the contract ID (string `C...`) whose + * balance of `sac` you want to know + * @param {Asset} sac the built-in SAC token (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}, which will contain the balance + * entry details if and only if the request returned a valid balance ledger + * entry. If it doesn't, the `balanceEntry` field will not exist. + * + * @throws {TypeError} If `contractId` is not a valid contract strkey (C...). + * + * @see getLedgerEntries + * @see https://developers.stellar.org/docs/tokens/stellar-asset-contract + * + * @example + * // assume `contractId` is some contract with an XLM balance + * // assume server is an instantiated `Server` instance. + * const entry = (await server.getSACBalance( + * new Address(contractId), + * Asset.native(), + * Networks.PUBLIC + * )); + * + * // assumes BigInt support: + * console.log( + * entry.balanceEntry ? + * BigInt(entry.balanceEntry.amount) : + * "Contract has no XLM"); + */ + public async getSACBalance( + contractId: string, + sac: Asset, + networkPassphrase?: string + ): Promise { + 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 SAC into predictable contract ID + const sacId = sac.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(sacId).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 }; + } + + const entry = scValToNative(val.contractData().val()); + + // Since we are requesting a SAC's contract data, we know for a fact that + // it should follow the expected structure format. Thus, we can presume + // these fields exist: + return { + latestLedger: response.latestLedger, + balanceEntry: { + liveUntilLedgerSeq, + lastModifiedLedgerSeq, + amount: entry.amount.toString(), + authorized: entry.authorized, + clawback: entry.clawback, + } + }; + } } diff --git a/test/unit/server/soroban/get_contract_balance_test.js b/test/unit/server/soroban/get_contract_balance_test.js new file mode 100644 index 000000000..6da8315a0 --- /dev/null +++ b/test/unit/server/soroban/get_contract_balance_test.js @@ -0,0 +1,153 @@ +const { Address, Keypair, 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) { + let result = { + latestLedger: 1000, + entries: [ + { + lastModifiedLedgerSeq: 1, + liveUntilLedgerSeq: 1000, + key: contractBalanceKey.toXDR("base64"), + xdr: contractBalanceEntry.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 balance entry", function (done) { + buildMockResult(this); + + this.server + .getSACBalance(contract, token, StellarSdk.Networks.TESTNET) + .then((response) => { + expect(response.latestLedger).to.equal(1000); + expect(response.balanceEntry).to.not.be.undefined; + expect(response.balanceEntry.amount).to.equal("1000000000000"); + expect(response.balanceEntry.authorized).to.be.true; + expect(response.balanceEntry.clawback).to.be.false; + done(); + }) + .catch((err) => done(err)); + }); + + it("infers the network passphrase", function (done) { + buildMockResult(this); + + 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 + .getSACBalance(contract, token) + .then((response) => { + expect(response.latestLedger).to.equal(1000); + expect(response.balanceEntry).to.not.be.undefined; + expect(response.balanceEntry.amount).to.equal("1000000000000"); + expect(response.balanceEntry.authorized).to.be.true; + expect(response.balanceEntry.clawback).to.be.false; + done(); + }) + .catch((err) => done(err)); + }); + + it("throws on invalid addresses", function (done) { + this.server + .getSACBalance(Keypair.random().publicKey(), token) + .then(() => done(new Error("Error didn't occur"))) + .catch((err) => { + expect(err).to.match(/TypeError/); + }); + + this.server + .getSACBalance(contract.substring(0, -1), token) + .then(() => done(new Error("Error didn't occur"))) + .catch((err) => { + expect(err).to.match(/TypeError/); + done(); + }); + }); +});