Skip to content

Commit

Permalink
Add Server.getSACBalance for fetching built-in token balance entries (
Browse files Browse the repository at this point in the history
#1046)

* Add helper for fetching a contract's balance
* Add comments to clarify types
* Add example to docs
* Add unhappy path tests
* Add changelog entry
  • Loading branch information
Shaptic authored Sep 19, 2024
1 parent 99cf4cc commit 10e12cf
Show file tree
Hide file tree
Showing 4 changed files with 297 additions and 1 deletion.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 15 additions & 1 deletion src/rpc/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ export namespace Api {
transactionData: string;
};

/** State Difference information */
/** State difference information */
stateChanges?: RawLedgerEntryChange[];
}

Expand Down Expand Up @@ -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;
};
}
}
110 changes: 110 additions & 0 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 @@ -897,4 +901,110 @@ export class Server {
public async getVersionInfo(): Promise<Api.GetVersionInfoResponse> {
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<Api.BalanceResponse>}, 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<Api.BalanceResponse> {
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,
}
};
}
}
153 changes: 153 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,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();
});
});
});

0 comments on commit 10e12cf

Please sign in to comment.