diff --git a/.changeset/honest-plums-work.md b/.changeset/honest-plums-work.md new file mode 100644 index 00000000000..2c23bf5a532 --- /dev/null +++ b/.changeset/honest-plums-work.md @@ -0,0 +1,6 @@ +--- +"@fuel-ts/abi-coder": minor +"@fuel-ts/program": minor +--- + +Add StdString dynamic string type diff --git a/packages/abi-coder/src/abi-coder.ts b/packages/abi-coder/src/abi-coder.ts index 034be96ef46..397687b2711 100644 --- a/packages/abi-coder/src/abi-coder.ts +++ b/packages/abi-coder/src/abi-coder.ts @@ -11,6 +11,7 @@ import { EnumCoder } from './coders/enum'; import { NumberCoder } from './coders/number'; import { OptionCoder } from './coders/option'; import { RawSliceCoder } from './coders/raw-slice'; +import { StdStringCoder } from './coders/stdString'; import { StringCoder } from './coders/string'; import { StructCoder } from './coders/struct'; import { TupleCoder } from './coders/tuple'; @@ -25,6 +26,7 @@ import { OPTION_CODER_TYPE, VEC_CODER_TYPE, BYTES_CODER_TYPE, + STD_STRING_CODER_TYPE, } from './constants'; import type { JsonAbi, JsonAbiArgument } from './json-abi'; import { ResolvedAbiType } from './resolved-abi-type'; @@ -69,6 +71,8 @@ export abstract class AbiCoder { return new B512Coder(); case BYTES_CODER_TYPE: return new ByteCoder(); + case STD_STRING_CODER_TYPE: + return new StdStringCoder(); default: break; } diff --git a/packages/abi-coder/src/coders/stdString.test.ts b/packages/abi-coder/src/coders/stdString.test.ts new file mode 100644 index 00000000000..6c7a039cdfe --- /dev/null +++ b/packages/abi-coder/src/coders/stdString.test.ts @@ -0,0 +1,92 @@ +import type { Uint8ArrayWithDynamicData } from '../utilities'; + +import { StdStringCoder } from './stdString'; + +describe('StdStringCoder', () => { + it('should encode an empty string', () => { + const coder = new StdStringCoder(); + const expected: Uint8ArrayWithDynamicData = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + expected.dynamicData = { + 0: new Uint8Array([]), + }; + + const actual = coder.encode(''); + expect(actual).toStrictEqual(expected); + }); + + it('should encode [hello world]', () => { + const coder = new StdStringCoder(); + const expected: Uint8ArrayWithDynamicData = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 11, + ]); + expected.dynamicData = { + 0: new Uint8Array([104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 0, 0, 0, 0, 0]), + }; + + const actual = coder.encode('hello world'); + expect(actual).toStrictEqual(expected); + }); + + it('should encode [H3llo W0rld]', () => { + const coder = new StdStringCoder(); + const expected: Uint8ArrayWithDynamicData = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 11, + ]); + expected.dynamicData = { + 0: new Uint8Array([72, 51, 108, 108, 111, 32, 87, 48, 114, 108, 100, 0, 0, 0, 0, 0]), + }; + + const actual = coder.encode('H3llo W0rld'); + expect(actual).toStrictEqual(expected); + }); + + it('should encode [abcdefghijklmnopqrstuvwxyz1234567890]', () => { + const coder = new StdStringCoder(); + const expected: Uint8ArrayWithDynamicData = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, 0, 0, 0, 36, + ]); + expected.dynamicData = { + 0: new Uint8Array([ + 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, + 116, 117, 118, 119, 120, 121, 122, 49, 50, 51, 52, 53, 54, 55, 56, 57, 48, 0, 0, 0, 0, + ]), + }; + + const actual = coder.encode('abcdefghijklmnopqrstuvwxyz1234567890'); + expect(actual).toStrictEqual(expected); + }); + + it('should decode a string', () => { + const coder = new StdStringCoder(); + const input = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 49, 120, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 11, 72, 101, 108, + 108, 111, 32, 87, 111, 114, 108, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + const expected = 'Hello World'; + + const [actual, newOffset] = coder.decode(input, 0); + + expect(actual).toEqual(expected); + expect(newOffset).toEqual(24); + }); + + it('should decode a string [with offset]', () => { + const coder = new StdStringCoder(); + const input = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 49, 120, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 11, 72, 101, 108, + 108, 111, 32, 87, 111, 114, 108, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + const expected = 'Hello World'; + + const [actual, newOffset] = coder.decode(input, 16); + + expect(actual).toEqual(expected); + expect(newOffset).toEqual(40); + }); +}); diff --git a/packages/abi-coder/src/coders/stdString.ts b/packages/abi-coder/src/coders/stdString.ts new file mode 100644 index 00000000000..b853439c5a0 --- /dev/null +++ b/packages/abi-coder/src/coders/stdString.ts @@ -0,0 +1,59 @@ +import { concat } from '@ethersproject/bytes'; +import { toUtf8Bytes, toUtf8String } from '@ethersproject/strings'; +import { bn } from '@fuel-ts/math'; + +import { WORD_SIZE } from '../constants'; +import type { Uint8ArrayWithDynamicData } from '../utilities'; +import { BASE_VECTOR_OFFSET, concatWithDynamicData } from '../utilities'; + +import { Coder } from './abstract-coder'; +import { U64Coder } from './u64'; + +export class StdStringCoder extends Coder { + static memorySize = 1; + constructor() { + super('struct', 'struct String', BASE_VECTOR_OFFSET); + } + + encode(value: string): Uint8Array { + const parts: Uint8Array[] = []; + + // pointer (ptr) + const pointer: Uint8ArrayWithDynamicData = new U64Coder().encode(BASE_VECTOR_OFFSET); + + // pointer dynamicData, encode the string vector now and attach to its pointer + const data = this.#getPaddedData(value); + pointer.dynamicData = { + 0: concatWithDynamicData([data]), + }; + + parts.push(pointer); + + // capacity (cap) + parts.push(new U64Coder().encode(data.byteLength)); + + // length (len) + parts.push(new U64Coder().encode(value.length)); + + return concatWithDynamicData(parts); + } + + #getPaddedData(value: string): Uint8Array { + const data: Uint8Array[] = [toUtf8Bytes(value)]; + + const paddingLength = (WORD_SIZE - (value.length % WORD_SIZE)) % WORD_SIZE; + if (paddingLength) { + data.push(new Uint8Array(paddingLength)); + } + + return concat(data); + } + + decode(data: Uint8Array, offset: number): [string, number] { + const len = data.slice(16, 24); + const length = bn(new U64Coder().decode(len, 0)[0]).toNumber(); + const byteData = data.slice(BASE_VECTOR_OFFSET, BASE_VECTOR_OFFSET + length); + const value = toUtf8String(byteData); + return [value, offset + BASE_VECTOR_OFFSET]; + } +} diff --git a/packages/abi-coder/src/constants.ts b/packages/abi-coder/src/constants.ts index 70906ea229a..b6c3ac599c8 100644 --- a/packages/abi-coder/src/constants.ts +++ b/packages/abi-coder/src/constants.ts @@ -1,6 +1,7 @@ export const OPTION_CODER_TYPE = 'enum Option'; export const VEC_CODER_TYPE = 'struct Vec'; export const BYTES_CODER_TYPE = 'struct Bytes'; +export const STD_STRING_CODER_TYPE = 'struct String'; export const stringRegEx = /str\[(?[0-9]+)\]/; export const arrayRegEx = /\[(?[\w\s\\[\]]+);\s*(?[0-9]+)\]/; export const structRegEx = /^struct (?\w+)$/; diff --git a/packages/abi-coder/src/utilities.ts b/packages/abi-coder/src/utilities.ts index 780ff022884..9d5d3b6023b 100644 --- a/packages/abi-coder/src/utilities.ts +++ b/packages/abi-coder/src/utilities.ts @@ -3,7 +3,7 @@ import { concat, arrayify } from '@ethersproject/bytes'; import { ErrorCode, FuelError } from '@fuel-ts/errors'; import { U64Coder } from './coders/u64'; -import { BYTES_CODER_TYPE, VEC_CODER_TYPE, WORD_SIZE } from './constants'; +import { BYTES_CODER_TYPE, VEC_CODER_TYPE, STD_STRING_CODER_TYPE, WORD_SIZE } from './constants'; export type DynamicData = { [pointerIndex: number]: Uint8ArrayWithDynamicData; @@ -143,7 +143,8 @@ export const isPointerType = (type: string) => { } }; -export const isHeapType = (type: string) => type === VEC_CODER_TYPE || type === BYTES_CODER_TYPE; +export const isHeapType = (type: string) => + type === VEC_CODER_TYPE || type === BYTES_CODER_TYPE || type === STD_STRING_CODER_TYPE; export function findOrThrow( arr: readonly T[], diff --git a/packages/abi-coder/test/interface.test.ts b/packages/abi-coder/test/interface.test.ts index 35e82501913..261ac169c7d 100644 --- a/packages/abi-coder/test/interface.test.ts +++ b/packages/abi-coder/test/interface.test.ts @@ -307,7 +307,6 @@ describe('Abi interface', () => { const data = (decoded as BN[]).slice(0, 3); return Array.from(data); }, - decodedTransfoarmer: (decoded: unknown | undefined) => Array.from(decoded as Uint8Array), }, { fn: exhaustiveExamplesInterface.functions.raw_slice, @@ -322,6 +321,15 @@ describe('Abi interface', () => { return data.map((v: BN) => v.toNumber()); }, }, + { + fn: exhaustiveExamplesInterface.functions.dynamic_string, + title: '[struct String]', + value: 'H3llo W0rld', + encodedValue: new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 11, 72, 51, 108, + 108, 111, 32, 87, 48, 114, 108, 100, 0, 0, 0, 0, 0, + ]), + }, { fn: exhaustiveExamplesInterface.functions.tuple_as_param, title: '[tuple] as param', diff --git a/packages/abi-coder/test/sway-projects/exhaustive-examples/src/main.sw b/packages/abi-coder/test/sway-projects/exhaustive-examples/src/main.sw index 6076d90be3c..a6fc539a8f2 100644 --- a/packages/abi-coder/test/sway-projects/exhaustive-examples/src/main.sw +++ b/packages/abi-coder/test/sway-projects/exhaustive-examples/src/main.sw @@ -1,6 +1,7 @@ contract; use std::b512::B512; use std::bytes::Bytes; +use std::string::String; enum EnumWithGeneric { VariantOne: T, @@ -135,6 +136,7 @@ abi MyContract { fn struct_with_implicitGenerics(arg: StructWithImplicitGenerics) -> StructWithImplicitGenerics; fn bytes(arg: Bytes) -> Bytes; fn raw_slice(arg: raw_slice) -> raw_slice; + fn dynamic_string(arg: String) -> String; fn tuple_as_param(x: (u8, StructA, str[3]>)) -> (u8, StructA, str[3]>); fn array_simple(x: [u8; 4]) -> [u8; 4]; @@ -253,6 +255,10 @@ impl MyContract for Contract { fn raw_slice(arg: raw_slice) -> raw_slice { arg } + + fn dynamic_string(arg: String) -> String { + arg + } fn two_u8_vectors(x: Vec, y: Vec) -> (Vec, Vec) { (x, y) diff --git a/packages/fuel-gauge/fixtures/forc-projects/Forc.toml b/packages/fuel-gauge/fixtures/forc-projects/Forc.toml index 554cc97f7b4..4cbb241bbdd 100644 --- a/packages/fuel-gauge/fixtures/forc-projects/Forc.toml +++ b/packages/fuel-gauge/fixtures/forc-projects/Forc.toml @@ -22,6 +22,7 @@ members = [ "predicate-main-args-vector", "predicate-multi-args", "predicate-raw-slice", + "predicate-std-lib-string", "predicate-struct", "predicate-triple-sig", "predicate-true", @@ -35,11 +36,13 @@ members = [ "script-main-return-struct", "script-main-two-args", "script-raw-slice", + "script-std-lib-string", "script-with-configurable", "script-with-array", "script-with-vector", "script-with-vector-advanced", "script-with-vector-mixed", + "std-lib-string", "storage-test-contract", "token_abi", "token_contract", diff --git a/packages/fuel-gauge/fixtures/forc-projects/predicate-std-lib-string/Forc.toml b/packages/fuel-gauge/fixtures/forc-projects/predicate-std-lib-string/Forc.toml new file mode 100644 index 00000000000..29c4645037a --- /dev/null +++ b/packages/fuel-gauge/fixtures/forc-projects/predicate-std-lib-string/Forc.toml @@ -0,0 +1,7 @@ +[project] +authors = ["FuelLabs"] +entry = "main.sw" +license = "Apache-2.0" +name = "predicate-std-lib-string" + +[dependencies] \ No newline at end of file diff --git a/packages/fuel-gauge/fixtures/forc-projects/predicate-std-lib-string/src/main.sw b/packages/fuel-gauge/fixtures/forc-projects/predicate-std-lib-string/src/main.sw new file mode 100644 index 00000000000..13594352ffa --- /dev/null +++ b/packages/fuel-gauge/fixtures/forc-projects/predicate-std-lib-string/src/main.sw @@ -0,0 +1,30 @@ +predicate; + +use std::string::String; + +fn validate_string(arg: String) -> bool { + // to be replaced with a simpler assert_eq once + // https://github.com/FuelLabs/sway/issues/4868 is done + let bytes = arg.as_bytes(); + + let inner = String::from_ascii_str("Hello World"); + let expected_bytes = inner.as_bytes(); + + if expected_bytes.len() != bytes.len() { + return false; + } + + let mut i = 0; + while i < bytes.len() { + if expected_bytes.get(i).unwrap() != bytes.get(i).unwrap() { + return false; + } + i += 1; + } + + true +} + +fn main(_arg_0: u64, _arg_1: u64, arg_2: String) -> bool { + validate_string(arg_2) +} diff --git a/packages/fuel-gauge/fixtures/forc-projects/script-std-lib-string/Forc.toml b/packages/fuel-gauge/fixtures/forc-projects/script-std-lib-string/Forc.toml new file mode 100644 index 00000000000..cdda6fb118b --- /dev/null +++ b/packages/fuel-gauge/fixtures/forc-projects/script-std-lib-string/Forc.toml @@ -0,0 +1,7 @@ +[project] +authors = ["FuelLabs"] +entry = "main.sw" +license = "Apache-2.0" +name = "script-std-lib-string" + +[dependencies] \ No newline at end of file diff --git a/packages/fuel-gauge/fixtures/forc-projects/script-std-lib-string/src/main.sw b/packages/fuel-gauge/fixtures/forc-projects/script-std-lib-string/src/main.sw new file mode 100644 index 00000000000..14ed91e311e --- /dev/null +++ b/packages/fuel-gauge/fixtures/forc-projects/script-std-lib-string/src/main.sw @@ -0,0 +1,24 @@ +script; + +use std::string::String; + +fn validate_string(arg: String) { + // to be replaced with a simpler assert_eq once + // https://github.com/FuelLabs/sway/issues/4868 is done + let bytes = arg.as_bytes(); + + let inner = String::from_ascii_str("Hello World"); + let expected_bytes = inner.as_bytes(); + + assert_eq(expected_bytes.len(), bytes.len()); + + let mut i = 0; + while i < bytes.len() { + assert(expected_bytes.get(i).unwrap() == bytes.get(i).unwrap()); + i += 1; + } +} + +fn main(arg: String) { + validate_string(arg); +} diff --git a/packages/fuel-gauge/fixtures/forc-projects/std-lib-string/Forc.toml b/packages/fuel-gauge/fixtures/forc-projects/std-lib-string/Forc.toml new file mode 100644 index 00000000000..ce43706b3b0 --- /dev/null +++ b/packages/fuel-gauge/fixtures/forc-projects/std-lib-string/Forc.toml @@ -0,0 +1,7 @@ +[project] +authors = ["FuelLabs"] +entry = "main.sw" +license = "Apache-2.0" +name = "std-lib-string" + +[dependencies] \ No newline at end of file diff --git a/packages/fuel-gauge/fixtures/forc-projects/std-lib-string/src/main.sw b/packages/fuel-gauge/fixtures/forc-projects/std-lib-string/src/main.sw new file mode 100644 index 00000000000..ff1cb333a38 --- /dev/null +++ b/packages/fuel-gauge/fixtures/forc-projects/std-lib-string/src/main.sw @@ -0,0 +1,37 @@ +contract; + +use std::string::String; +use std::assert::assert_eq; +use std::bytes::Bytes; + +abi MyContract { + fn return_dynamic_string() -> String; + fn accepts_dynamic_string(s: String); +} + +fn validate_string(arg: String) { + // to be replaced with a simpler assert_eq once + // https://github.com/FuelLabs/sway/issues/4868 is done + let bytes = arg.as_bytes(); + + let inner = String::from_ascii_str("Hello World"); + let expected_bytes = inner.as_bytes(); + + assert_eq(expected_bytes.len(), bytes.len()); + + let mut i = 0; + while i < bytes.len() { + assert(expected_bytes.get(i).unwrap() == bytes.get(i).unwrap()); + i += 1; + } +} + +impl MyContract for Contract { + fn return_dynamic_string() -> String { + String::from_ascii_str("Hello World") + } + + fn accepts_dynamic_string(s: String) { + validate_string(s); + } +} diff --git a/packages/fuel-gauge/src/std-lib-string.test.ts b/packages/fuel-gauge/src/std-lib-string.test.ts new file mode 100644 index 00000000000..36d77440def --- /dev/null +++ b/packages/fuel-gauge/src/std-lib-string.test.ts @@ -0,0 +1,92 @@ +import { generateTestWallet } from '@fuel-ts/wallet/test-utils'; +import type { BN } from 'fuels'; +import { + type Contract, + bn, + Predicate, + Wallet, + Address, + BaseAssetId, + Provider, + FUEL_NETWORK_URL, +} from 'fuels'; + +import predicateStdString from '../fixtures/forc-projects/predicate-std-lib-string'; +import predicateStdStringAbi from '../fixtures/forc-projects/predicate-std-lib-string/out/debug/predicate-std-lib-string-abi.json'; + +import { getScript, getSetupContract } from './utils'; + +const setupContract = getSetupContract('std-lib-string'); +let contractInstance: Contract; +beforeAll(async () => { + contractInstance = await setupContract(); +}); + +const setup = async (balance = 5_000) => { + const provider = await Provider.create(FUEL_NETWORK_URL); + + // Create wallet + const wallet = await generateTestWallet(provider, [[balance, BaseAssetId]]); + + return wallet; +}; + +describe('std-lib-string Tests', () => { + it('should test std-lib-string return', async () => { + const { value } = await contractInstance.functions.return_dynamic_string().call(); + expect(value).toBe('Hello World'); + }); + + it('should test std-lib-string input', async () => { + const INPUT = 'Hello World'; + + const { value } = await contractInstance.functions.accepts_dynamic_string(INPUT).call(); + + expect(value).toBeUndefined(); + }); + + it('should test String input [predicate-std-lib-string]', async () => { + const wallet = await setup(); + const receiver = Wallet.fromAddress(Address.fromRandom(), wallet.provider); + const amountToPredicate = 100; + const amountToReceiver = 50; + type MainArgs = [number, number, string]; + const predicate = new Predicate( + predicateStdString, + wallet.provider, + predicateStdStringAbi + ); + + // setup predicate + const setupTx = await wallet.transfer(predicate.address, amountToPredicate, BaseAssetId); + await setupTx.waitForResult(); + + const initialPredicateBalance = await predicate.getBalance(); + const initialReceiverBalance = await receiver.getBalance(); + const tx = await predicate + .setData(1, 2, 'Hello World') + .transfer(receiver.address, amountToReceiver); + await tx.waitForResult(); + + // Check the balance of the receiver + const finalReceiverBalance = await receiver.getBalance(); + expect(bn(initialReceiverBalance).add(amountToReceiver).toHex()).toEqual( + finalReceiverBalance.toHex() + ); + + // Check we spent the entire predicate hash input + const finalPredicateBalance = await predicate.getBalance(); + expect(finalPredicateBalance.lte(initialPredicateBalance)).toBeTruthy(); + }); + + it('should test String input [script-std-lib-string]', async () => { + const wallet = await setup(); + type MainArgs = [string]; + const scriptInstance = getScript('script-std-lib-string', wallet); + const INPUT = 'Hello World'; + + const { value } = await scriptInstance.functions.main(INPUT).call(); + + expect(value.toNumber()).toStrictEqual(0); + }); +});