@btc-vision/btc-runtime
Version:
Bitcoin Smart Contract Runtime
204 lines (175 loc) • 6.88 kB
text/typescript
import { Blockchain } from '../env';
import { encodePointer } from '../math/abi';
import { bigEndianAdd } from '../math/bytes';
import { Revert } from '../types/Revert';
import { u256 } from '@btc-vision/as-bignum/assembly';
import { SafeMath } from '../types/SafeMath';
const MAX_LENGTH = <u32>u16.MAX_VALUE;
const MAX_LENGTH_U256 = u256.fromU32(<u32>MAX_LENGTH);
/**
* @class StoredString
* @description
* Stores a string in a sequence of 32-byte storage slots, in UTF-8 format:
* - Slot 0: first 4 bytes = length (big-endian), next 28 bytes = partial data
* - Slot N>0: 32 bytes of data each
*
* The maximum is 65,535 bytes in UTF-8 form (not necessarily the same as code points).
*/
export class StoredString {
private readonly subPointer: Uint8Array;
constructor(public pointer: u16, index: u64 = 0) {
const indexed = SafeMath.mul(u256.fromU64(index), MAX_LENGTH_U256);
this.subPointer = indexed.toUint8Array(true).slice(0, 30);
}
private _value: string = '';
/**
* Cached string value. If `_value` is empty, we call `load()` on first access.
*/
public get value(): string {
if (!this._value) {
this.load();
}
return this._value;
}
public set value(v: string) {
this._value = v;
this.save();
}
/**
* Derives a 32-byte pointer for the given chunkIndex and performs big-endian addition.
* chunkIndex=0 => header slot, 1 => second slot, etc.
*/
private getPointer(chunkIndex: u64): Uint8Array {
const base = encodePointer(this.pointer, this.subPointer);
return bigEndianAdd(base, chunkIndex);
}
/**
* Reads the first slot and returns the stored byte length (big-endian).
* Returns 0 if the slot is all zero.
*/
private getStoredLength(): u32 {
const headerSlot = Blockchain.getStorageAt(this.getPointer(0));
const b0 = <u32>headerSlot[0];
const b1 = <u32>headerSlot[1];
const b2 = <u32>headerSlot[2];
const b3 = <u32>headerSlot[3];
return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
}
/**
* Clears old data from storage. Based on `oldLength`, determines how many slots
* were used, and writes zeroed 32-byte arrays to each.
*/
private clearOldStorage(oldLength: u32): void {
if (oldLength == 0) {
return;
}
// We always use at least 1 slot (the header slot).
let chunkCount: u64 = 1;
// In the header slot, we can store up to 28 bytes of data.
const remaining = oldLength > 28 ? oldLength - 28 : 0;
if (remaining > 0) {
// Each additional chunk is 32 bytes.
// Use integer math ceiling: (remaining + 32 - 1) / 32
chunkCount += (remaining + 32 - 1) / 32;
}
// Zero out each previously used slot
for (let i: u64 = 0; i < chunkCount; i++) {
Blockchain.setStorageAt(this.getPointer(i), new Uint8Array(32));
}
}
/**
* Saves the current string to storage in UTF-8 form.
*/
private save(): void {
// 1) Clear old data
const oldLen = this.getStoredLength();
this.clearOldStorage(oldLen);
// 2) Encode new string as UTF-8
const utf8Data = String.UTF8.encode(this._value, false);
const length = <u32>utf8Data.byteLength;
// Enforce max length
if (length > MAX_LENGTH) {
throw new Revert(`StoredString: value is too long (max=${MAX_LENGTH})`);
}
// 3) If new string is empty, just store a zeroed header and return
if (length == 0) {
// A zeroed 32-byte array => indicates length=0
Blockchain.setStorageAt(this.getPointer(0), new Uint8Array(32));
return;
}
// 4) Write the first slot: length + up to 28 bytes
let remaining: u32 = length;
let offset: u32 = 0;
const firstSlot = new Uint8Array(32);
firstSlot[0] = <u8>((length >> 24) & 0xff);
firstSlot[1] = <u8>((length >> 16) & 0xff);
firstSlot[2] = <u8>((length >> 8) & 0xff);
firstSlot[3] = <u8>(length & 0xff);
const bytes = Uint8Array.wrap(utf8Data);
const firstChunkSize = remaining < 28 ? remaining : 28;
for (let i: u32 = 0; i < firstChunkSize; i++) {
firstSlot[4 + i] = bytes[i];
}
Blockchain.setStorageAt(this.getPointer(0), firstSlot);
remaining -= firstChunkSize;
offset += firstChunkSize;
// 5) Write subsequent slots (32 bytes each)
let chunkIndex: u64 = 1;
while (remaining > 0) {
const slotData = new Uint8Array(32);
const chunkSize = remaining < u32(32) ? remaining : u32(32);
for (let i: u32 = 0; i < chunkSize; i++) {
slotData[i] = bytes[offset + i];
}
Blockchain.setStorageAt(this.getPointer(chunkIndex), slotData);
remaining -= chunkSize;
offset += chunkSize;
chunkIndex++;
}
}
/**
* Loads the string from storage by reading the stored byte length, then decoding
* the corresponding UTF-8 data from the slots.
*/
private load(): void {
// Read the header slot first
const headerSlot = Blockchain.getStorageAt(this.getPointer(0));
// Parse the big-endian length
const b0 = <u32>headerSlot[0];
const b1 = <u32>headerSlot[1];
const b2 = <u32>headerSlot[2];
const b3 = <u32>headerSlot[3];
const length: u32 = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
// If length=0, then the string is empty
if (length == 0) {
this._value = '';
return;
}
// Read the UTF-8 bytes from storage
let remaining: u32 = length;
let offset: u32 = 0;
const out = new Uint8Array(length);
// First slot can hold up to 28 bytes after the length
const firstChunkSize = remaining < 28 ? remaining : 28;
for (let i: u32 = 0; i < firstChunkSize; i++) {
out[i] = headerSlot[4 + i];
}
remaining -= firstChunkSize;
offset += firstChunkSize;
// Read the subsequent slots of 32 bytes each
let chunkIndex: u64 = 1;
while (remaining > 0) {
const slotData = Blockchain.getStorageAt(this.getPointer(chunkIndex));
const chunkSize = remaining < 32 ? remaining : 32;
for (let i: u32 = 0; i < chunkSize; i++) {
out[offset + i] = slotData[i];
}
remaining -= chunkSize;
offset += chunkSize;
chunkIndex++;
}
// Decode UTF-8 into a normal string
this._value = String.UTF8.decode(out.buffer, false);
}
}