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
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;
sreuland marked this conversation as resolved.
Show resolved Hide resolved
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(
Shaptic marked this conversation as resolved.
Show resolved Hide resolved
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));
});

sreuland marked this conversation as resolved.
Show resolved Hide resolved
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();
});
});
});
Loading