|
| 1 | +/** |
| 2 | + * Typed CRUD manager for Stellar account data entries. |
| 3 | + * |
| 4 | + * Wraps `Operation.manageData()` with validation for the protocol's 64-byte |
| 5 | + * key/value limits and 64-entry-per-account cap, so callers can store custom |
| 6 | + * metadata alongside SDK state without hand-rolling raw manageData calls. |
| 7 | + */ |
| 8 | + |
| 9 | +import { |
| 10 | + Account, |
| 11 | + Horizon, |
| 12 | + Keypair, |
| 13 | + Operation, |
| 14 | + TransactionBuilder, |
| 15 | + BASE_FEE, |
| 16 | +} from "@stellar/stellar-sdk"; |
| 17 | +import type { AccountDataMap } from "./types.js"; |
| 18 | +import { DataEntryValidationError } from "./errors.js"; |
| 19 | + |
| 20 | +/** Stellar protocol limit for both data entry keys and values, in bytes. */ |
| 21 | +const MAX_DATA_ENTRY_BYTES = 64; |
| 22 | + |
| 23 | +/** Stellar protocol limit on the number of data entries per account. */ |
| 24 | +const MAX_DATA_ENTRIES = 64; |
| 25 | + |
| 26 | +/** Result of submitting a manageData transaction. */ |
| 27 | +export interface TransactionResult { |
| 28 | + txHash: string; |
| 29 | +} |
| 30 | + |
| 31 | +/** Configuration for {@link AccountDataManager}. */ |
| 32 | +export interface AccountDataManagerConfig { |
| 33 | + /** Horizon server URL. */ |
| 34 | + horizonUrl: string; |
| 35 | + /** Stellar network passphrase. */ |
| 36 | + networkPassphrase: string; |
| 37 | +} |
| 38 | + |
| 39 | +function byteLength(value: string): number { |
| 40 | + return Buffer.byteLength(value, "utf8"); |
| 41 | +} |
| 42 | + |
| 43 | +/** |
| 44 | + * Typed CRUD manager for account data entries, built on top of |
| 45 | + * `Operation.manageData()` and `Server.loadAccount().data_attr`. |
| 46 | + */ |
| 47 | +export class AccountDataManager { |
| 48 | + private readonly server: Horizon.Server; |
| 49 | + private readonly networkPassphrase: string; |
| 50 | + |
| 51 | + constructor(config: AccountDataManagerConfig) { |
| 52 | + this.server = new Horizon.Server(config.horizonUrl); |
| 53 | + this.networkPassphrase = config.networkPassphrase; |
| 54 | + } |
| 55 | + |
| 56 | + /** |
| 57 | + * Set (create or update) a data entry on `accountId`. |
| 58 | + * |
| 59 | + * @throws DataEntryValidationError if the key/value exceed 64 bytes, or if |
| 60 | + * the account already has 64 entries and `key` is new. |
| 61 | + */ |
| 62 | + async set( |
| 63 | + accountId: string, |
| 64 | + key: string, |
| 65 | + value: string, |
| 66 | + signerSecret: string, |
| 67 | + ): Promise<TransactionResult> { |
| 68 | + await this.validateEntry(accountId, key, value); |
| 69 | + return this.submitManageData(accountId, key, value, signerSecret); |
| 70 | + } |
| 71 | + |
| 72 | + /** |
| 73 | + * Fetch the current value of `key` on `accountId`, or `null` if absent. |
| 74 | + */ |
| 75 | + async get(accountId: string, key: string): Promise<string | null> { |
| 76 | + const entries = await this.list(accountId); |
| 77 | + return Object.prototype.hasOwnProperty.call(entries, key) ? entries[key]! : null; |
| 78 | + } |
| 79 | + |
| 80 | + /** |
| 81 | + * Delete a data entry by submitting `manageData` with a `null` value. |
| 82 | + */ |
| 83 | + async delete( |
| 84 | + accountId: string, |
| 85 | + key: string, |
| 86 | + signerSecret: string, |
| 87 | + ): Promise<TransactionResult> { |
| 88 | + return this.submitManageData(accountId, key, null, signerSecret); |
| 89 | + } |
| 90 | + |
| 91 | + /** |
| 92 | + * Return all data entries currently stored on `accountId`, decoded from |
| 93 | + * base64 to UTF-8 strings. |
| 94 | + */ |
| 95 | + async list(accountId: string): Promise<AccountDataMap> { |
| 96 | + const account = await this.server.loadAccount(accountId); |
| 97 | + const raw = account.data_attr as Record<string, string> | undefined; |
| 98 | + const result: AccountDataMap = {}; |
| 99 | + for (const [key, base64Value] of Object.entries(raw ?? {})) { |
| 100 | + result[key] = Buffer.from(base64Value, "base64").toString("utf8"); |
| 101 | + } |
| 102 | + return result; |
| 103 | + } |
| 104 | + |
| 105 | + private async validateEntry(accountId: string, key: string, value: string): Promise<void> { |
| 106 | + if (byteLength(key) > MAX_DATA_ENTRY_BYTES) { |
| 107 | + throw new DataEntryValidationError( |
| 108 | + `key "${key}" exceeds ${MAX_DATA_ENTRY_BYTES} bytes`, |
| 109 | + { key }, |
| 110 | + ); |
| 111 | + } |
| 112 | + if (byteLength(value) > MAX_DATA_ENTRY_BYTES) { |
| 113 | + throw new DataEntryValidationError( |
| 114 | + `value for key "${key}" exceeds ${MAX_DATA_ENTRY_BYTES} bytes`, |
| 115 | + { key }, |
| 116 | + ); |
| 117 | + } |
| 118 | + |
| 119 | + const existing = await this.list(accountId); |
| 120 | + const isNewKey = !Object.prototype.hasOwnProperty.call(existing, key); |
| 121 | + if (isNewKey && Object.keys(existing).length >= MAX_DATA_ENTRIES) { |
| 122 | + throw new DataEntryValidationError( |
| 123 | + `account ${accountId} already has ${MAX_DATA_ENTRIES} data entries`, |
| 124 | + { accountId }, |
| 125 | + ); |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + private async submitManageData( |
| 130 | + accountId: string, |
| 131 | + key: string, |
| 132 | + value: string | null, |
| 133 | + signerSecret: string, |
| 134 | + ): Promise<TransactionResult> { |
| 135 | + const keypair = Keypair.fromSecret(signerSecret); |
| 136 | + const loaded = await this.server.loadAccount(accountId); |
| 137 | + const sourceAccount = new Account(loaded.accountId(), loaded.sequenceNumber()); |
| 138 | + |
| 139 | + const tx = new TransactionBuilder(sourceAccount, { |
| 140 | + fee: BASE_FEE, |
| 141 | + networkPassphrase: this.networkPassphrase, |
| 142 | + }) |
| 143 | + .addOperation(Operation.manageData({ name: key, value: value ?? null })) |
| 144 | + .setTimeout(30) |
| 145 | + .build(); |
| 146 | + |
| 147 | + tx.sign(keypair); |
| 148 | + const result = await this.server.submitTransaction(tx); |
| 149 | + return { txHash: result.hash }; |
| 150 | + } |
| 151 | +} |
0 commit comments