Transactions
Most “blockchain APIs” ask you to hand over a private key so the server can sign on your behalf. Smart Engines does not. Every value movement starts from an entity: an on-chain account whose signing key was created by DKG across the validators and exists only as shares. The cluster prepares the transaction, the validators sign it under the entity’s rules, and where the entity’s custody is shared with you, your own signature is required as well.
This is the transaction sovereignty model.
The flow
Section titled “The flow”sequenceDiagram
participant App as Your app
participant SDK as baas.entities
participant H as Host
participant V as Validators (TSS)
participant Chain as Blockchain
App->>SDK: transfer(entityId, { chain, to, amount })
SDK->>H: POST /api/v3/baas/entities/:entityId/transfer
H->>H: check the caller owns the entity
H->>V: prepare (the entity is the source and fee payer)
V->>V: build the tx, check the entity's rules, threshold-sign
V->>Chain: broadcast (Hedera, XRPL)
Chain-->>V: result
V-->>H: { transactionId, status, broadcast: true }
H-->>SDK: result
SDK-->>App: result
The entity is always the source and the fee payer. The host resolves both from the entity record, never from the request body, so a caller cannot name someone else’s account as the payer or drain an account it does not own.
Chain-agnostic methods
Section titled “Chain-agnostic methods”Every value-movement method takes a chain field. It is the ChainType union, never a hard-coded literal, and it must be a chain the entity has an account on. Two chains are live today:
| Chain | Status |
|---|---|
| XRPL | Live |
| Hedera | Live |
| Stellar, Polkadot, Solana, Bitcoin | Coming next |
| Ethereum, Polygon (EVM) | Postponed |
import { BaasClient } from '@hsuite/smart-engines-sdk';
const baas = await BaasClient.connectToCluster({ network: 'testnet', appId: 'my-app' });await baas.authenticate({ chain: 'xrpl', walletAddress, publicKey, signFn });
const result = await baas.entities.transfer('entity-123', { chain: 'xrpl', to: 'rRecipient...', amount: '1000000', // smallest unit: 1,000,000 drops = 1 XRP});Amounts are strings in the chain’s smallest unit (drops on XRPL, tinybar on Hedera), which avoids floating-point loss.
These calls act as the authenticated session wallet. To act for one of your end users, pass onBehalfOf (that user’s customer-session token) in the options bag. A forCustomer view refuses these calls without it.
The PreparedEntityTransactionResult you get back
Section titled “The PreparedEntityTransactionResult you get back”| Field | Type | Description |
|---|---|---|
chain |
string |
The chain the transaction targets |
transactionId |
string |
A pre-assigned id, or the real on-chain id when broadcast is true |
transactionBytes |
string |
The prepared transaction |
transactionType |
string |
e.g. Payment, CryptoTransfer |
payerAccountId |
string |
The fee payer: always the entity’s own account |
expiresAt |
string |
ISO expiry of the prepared transaction |
requiredSignatures |
number |
Signatures still needed; 0 means ready to submit |
readyToSubmit |
boolean |
Whether the bytes already carry enough signatures |
validatorSignatures |
EntityValidatorSignature[] |
The validators’ signatures (metadata) |
transactionJson |
object |
Chain-native transaction JSON (e.g. the decoded XRPL tx) |
broadcast |
boolean |
true when the validators submitted the transaction themselves. See Server-side broadcast |
status |
string |
The on-chain result (SUCCESS on Hedera, tesSUCCESS on XRPL). Present only when broadcast is true |
metadata |
object |
Chain-specific metadata |
idempotencyKey |
string |
Returned by prepareWithdraw. Echo it on executeWithdraw |
transfer
Section titled “transfer”Moves a native asset or a fungible token from an account entity.
const result = await baas.entities.transfer('entity-123', { chain: 'xrpl', to: 'rRecipient...', amount: '1000000', tokenId: 'HST.rIssuer...', // optional: omit for the native asset memo: 'invoice 42', // optional});| Field | Required | Notes |
|---|---|---|
chain |
yes | A chain the entity has an account on |
to |
yes | Destination account or address |
amount |
yes | String, in the chain’s smallest unit |
tokenId |
no | Omit for the chain’s native asset |
memo |
no | Carried by the transaction |
transfer has no execute half. An entity whose custody you share (it needs your co-signature) moves value with prepareWithdraw and executeWithdraw.
Server-side broadcast (Hedera + XRPL)
Section titled “Server-side broadcast (Hedera + XRPL)”On Hedera and XRPL, once a transfer or withdrawal carries every signature it needs, the validators submit it to the chain themselves and return the real on-chain result:
const result = await baas.entities.transfer('entity-123', { chain: 'xrpl', to: 'rDest...', amount: '1000000',});
if (result.broadcast) { // Already on chain: transactionId is the real on-chain id. console.log(result.status); // 'tesSUCCESS' (XRPL) / 'SUCCESS' (Hedera) console.log(result.transactionId);} else { // Still needs a signature (e.g. your co-signature on a shared-custody entity). console.log(result.requiredSignatures, result.transactionBytes);}A broadcast fails closed: if the submit fails, the call returns an error. It never reports success for a transaction that did not land.
Shared custody uses prepareWithdraw then executeWithdraw. When the entity needs your co-signature, prepare the withdrawal, sign the returned bytes with your wallet, and hand them back. The validators add their threshold signatures and broadcast:
const prepared = await baas.entities.prepareWithdraw('entity-123', { chain: 'xrpl', destination: 'rDest...', amount: '1000000', appOwnerPublicKey: '02ab...', // required for a shared-custody entity on XRPL and Hedera});
const signedTransactionBytes = await signWithYourWallet(prepared.transactionBytes);
const result = await baas.entities.executeWithdraw('entity-123', { chain: 'xrpl', signedTransactionBytes, appOwnerPublicKey: '02ab...', // the same key as the prepare idempotencyKey: prepared.idempotencyKey, // echoed verbatim});console.log(result.broadcast, result.status); // true, 'tesSUCCESS'Mints more supply of a fungible token whose supply authority is the entity (the entity is the issuer or treasury).
const result = await baas.entities.mint('entity-123', { chain: 'xrpl', tokenId: 'HST.rIssuer...', amount: '1000', memo: 'monthly issuance', // optional});Fields: chain, tokenId, amount (smallest unit), optional memo.
Burns supply of a token whose supply authority is the entity.
const result = await baas.entities.burn('entity-123', { chain: 'hedera', tokenId: '0.0.45678', amount: '500',});Fields: chain, tokenId, amount (smallest unit), optional memo.
Other prepare methods
Section titled “Other prepare methods”baas.entities also covers withdrawals, compliance, trust lines and topic messages. All take the entity id first and an options bag last:
| Method | Route (/api/v3/baas/entities/:entityId/...) |
Returns |
|---|---|---|
prepareWithdraw(id, { chain, destination, amount, tokenId?, memo?, appOwnerPublicKey?, idempotencyKey? }) |
withdraw/prepare |
PreparedEntityTransactionResult |
executeWithdraw(id, { chain, signedTransactionBytes, appOwnerPublicKey?, idempotencyKey? }) |
withdraw/execute |
ExecutedEntityTransactionResult |
compliance(id, { action, chain, tokenId, account?, amount?, memo? }) |
compliance |
PreparedEntityTransactionResult |
trustline(id, { chain, currency, issuerAddress, limit? }) |
trustline |
PreparedEntityTransactionResult |
prepareSubmitMessage(id, { message, payerAccountId? }) |
message |
PreparedEntityTransactionResult |
submitMessage(id, { message, payerAccountId?, fundWith }) |
message |
SubmitTopicMessageResult |
compliance takes action: 'pause' | 'restrict' | 'wipe'.
Chain-specific operations
Section titled “Chain-specific operations”A few operations exist on only one live chain:
| Operation | Chain | Notes |
|---|---|---|
trustline |
XRPL | Opens a trust line from the entity’s account to an issued currency |
prepareSubmitMessage / submitMessage |
Hedera | Submits a message (up to 1,024 bytes) to a topic entity. submitMessage pays the fee with your fundWith callback and returns the transactionId |
compliance |
XRPL | pause and restrict use XRPL freezes; wipe works only on a token created with it enabled. A token created through the API on Hedera has no pause, restrict or wipe key |
Handling rule denials and policy holds
Section titled “Handling rule denials and policy holds”A value-movement call can come back with two very different kinds of “no”, and a client has to tell them apart, because one is a permanent verdict and the other is the system pacing you.
| What happened | HTTP | Body carries | Retry? |
|---|---|---|---|
| A rule blocked the operation (for example the destination is not on the entity’s allowlist) | 403 |
the rule’s reason in message |
Never (isRetryable === false) |
| A rate limit, cooldown or velocity gate refused for now | 429 |
code: 'POLICY_DENIED' and retryAfterSeconds |
Yes, after the wait (isRetryable === true) |
A rule deny (403) — deterministic, don’t retry it
Section titled “A rule deny (403) — deterministic, don’t retry it”A rule deny reaches a BaasClient call as a 403 whose message is the rule’s reason. The validators never return signed bytes for a denied operation.
The SDK also exports an isRuleRejected type guard. It matches only a body that carries the full rule_rejected envelope (error: 'rule_rejected', reason and the ruleAtoms ids). Entity calls made through the host do not forward that envelope today: the reason arrives in message, ruleAtoms does not arrive, and isRuleRejected returns false. Treat any 403 from a value-movement call as final.
import { isSmartEngineSdkError } from '@hsuite/smart-engines-sdk';
try { await baas.entities.transfer('entity-123', { chain: 'xrpl', to: 'rDest...', amount: '1000000' });} catch (err) { if (isSmartEngineSdkError(err) && err.statusCode === 403) { // A verdict: show the reason, don't loop. console.log(`Blocked: ${err.message}`); // the rule's reason return; } throw err;}A policy hold (429) — transient, honor retryAfterSeconds
Section titled “A policy hold (429) — transient, honor retryAfterSeconds”A rate limit, cooldown or velocity gate is a temporary refusal, returned as a 429 with code: 'POLICY_DENIED' and a whole-second retryAfterSeconds in the body. err.isRetryable is true, and the correct move is to wait that long and retry the same request:
import { isSmartEngineSdkError } from '@hsuite/smart-engines-sdk';
async function transferWithPolicyRetry() { for (;;) { try { return await baas.entities.transfer('entity-123', { chain: 'xrpl', to: 'rDest...', amount: '1000000' }); } catch (err) { if ( isSmartEngineSdkError(err) && err.statusCode === 429 && err.details?.code === 'POLICY_DENIED' ) { const waitMs = (err.details.retryAfterSeconds ?? 60) * 1000; await new Promise((resolve) => setTimeout(resolve, waitMs)); continue; // same request, after the cooldown } throw err; } }}Because the same transfer sent within 15 minutes is merged into one operation, this retry cannot pay twice.
Full body shapes, the complete status-code table and a generic retry helper are in Error Handling.