diff --git a/deno.json b/deno.json index d411758..518b345 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight/moonlight-sdk", - "version": "0.5.0", + "version": "0.6.0", "description": "A privacy-focused toolkit for the Moonlight protocol on Stellar Soroban smart contracts.", "license": "MIT", "tasks": { diff --git a/src/custom-xdr/index.ts b/src/custom-xdr/index.ts index 371ba6c..7682df2 100644 --- a/src/custom-xdr/index.ts +++ b/src/custom-xdr/index.ts @@ -16,7 +16,12 @@ import { Buffer } from "buffer"; import { MLXDRPrefix, MLXDRTypeByte } from "./types.ts"; import type { Condition as ConditionType } from "../conditions/types.ts"; -import { nativeToScVal, scValToBigInt, xdr } from "@stellar/stellar-sdk"; +import { + nativeToScVal, + scValToBigInt, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; import { Condition } from "../conditions/index.ts"; import { type MoonlightOperation as MoonlightOperationType, @@ -38,6 +43,8 @@ const MLXDROperationBytes = [ MLXDRTypeByte.WithdrawOperation, ]; +const MLXDROperationsBundleBytes = [MLXDRTypeByte.OperationsBundle]; + const MLXDRTransactionBundleBytes = [MLXDRTypeByte.TransactionBundle]; const isMLXDR = (data: string): boolean => { @@ -93,6 +100,13 @@ const isOperation = (data: string): boolean => { return MLXDROperationBytes.includes(prefixByte); }; +const isOperationsBundle = (data: string): boolean => { + const typePrefix = getMLXDRTypePrefix(data); + + const prefixByte = typePrefix[0]; + return MLXDROperationsBundleBytes.includes(prefixByte); +}; + const isTransactionBundle = (data: string): boolean => { const typePrefix = getMLXDRTypePrefix(data); @@ -310,6 +324,52 @@ const MLXDRtoOperation = (data: string): MoonlightOperationType => { } }; +const operationsBundleToMLXDR = ( + operations: MoonlightOperationType[], +): string => { + if (operations.length === 0) { + throw new Error("Operations bundle cannot be empty"); + } + const operationMLXDRArray = operations.map((op) => { + return xdr.ScVal.scvString(op.toMLXDR()); + }); + const typeByte: MLXDRTypeByte = MLXDRTypeByte.OperationsBundle; + + const operationBundleXDR = xdr.ScVal.scvVec([ + ...operationMLXDRArray, + ]).toXDR("base64"); + + return appendMLXDRPrefixToRawXDR(operationBundleXDR, typeByte); +}; + +const MLXDRtoOperationsBundle = (data: string): MoonlightOperationType[] => { + if (!isOperationsBundle(data)) { + throw new Error("Data is not a valid MLXDR Operations Bundle"); + } + + const buffer = Buffer.from(data, "base64"); + const rawXDRBuffer = buffer.slice(3); + const rawXDRString = rawXDRBuffer.toString("base64"); + + const scVal = xdr.ScVal.fromXDR(rawXDRString, "base64"); + + const vec = scVal.vec(); + + if (vec === null) { + throw new Error("Invalid ScVal vector for operations bundle"); + } + + const operations: MoonlightOperationType[] = vec.map((opScVal) => { + if (opScVal.switch().name !== xdr.ScValType.scvString().name) { + throw new Error("Invalid ScVal type for operation in bundle"); + } + const opMLXDR = scValToNative(opScVal) as string; + return MLXDRtoOperation(opMLXDR); + }); + + return operations; +}; + /** * * MLXDR Module * @@ -330,10 +390,13 @@ export const MLXDR = { is: isMLXDR, isCondition, isOperation, + isOperationsBundle, isTransactionBundle, getXDRType, fromCondition: conditionToMLXDR, toCondition: MLXDRtoCondition, fromOperation: operationToMLXDR, toOperation: MLXDRtoOperation, + fromOperationsBundle: operationsBundleToMLXDR, + toOperationsBundle: MLXDRtoOperationsBundle, }; diff --git a/src/custom-xdr/index.unit.test.ts b/src/custom-xdr/index.unit.test.ts new file mode 100644 index 0000000..89c4fdb --- /dev/null +++ b/src/custom-xdr/index.unit.test.ts @@ -0,0 +1,261 @@ +import { assert, assertEquals, assertExists } from "@std/assert"; +import { beforeAll, describe, it } from "@std/testing/bdd"; +import { + type ContractId, + type Ed25519PublicKey, + LocalSigner, +} from "@colibri/core"; +import type { UTXOPublicKey } from "../core/utxo-keypair-base/types.ts"; +import { generateP256KeyPair } from "../utils/secp256r1/generateP256KeyPair.ts"; + +import { Asset, Networks } from "@stellar/stellar-sdk"; + +import { + type CreateOperation, + type DepositOperation, + type SpendOperation, + UTXOOperationType, + type WithdrawOperation, +} from "../operation/types.ts"; +import { MoonlightOperation } from "../operation/index.ts"; +import { UTXOKeypairBase } from "../core/utxo-keypair-base/index.ts"; +import { MLXDR } from "./index.ts"; + +describe("MLXDR", () => { + let validPublicKey: Ed25519PublicKey; + let validUtxo: UTXOPublicKey; + + let channelId: ContractId; + + let assetId: ContractId; + let network: string; + + beforeAll(async () => { + validPublicKey = LocalSigner.generateRandom() + .publicKey() as Ed25519PublicKey; + validUtxo = (await generateP256KeyPair()).publicKey as UTXOPublicKey; + + channelId = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" as ContractId; + + network = Networks.TESTNET; + assetId = Asset.native().contractId(network) as ContractId; + }); + + describe("OperationsBundle MLXDR", () => { + it("should verify if the Operations Bundle is a valid MLXDR", () => { + const createOp = MoonlightOperation.create(validUtxo, 10n); + + const opBundleMLXDR = MLXDR.fromOperationsBundle([createOp]); + + assertEquals(MLXDR.isOperationsBundle(opBundleMLXDR), true); + }); + it("should convert to and from one operation", () => { + const createOp = MoonlightOperation.create(validUtxo, 10n); + + const opBundleMLXDR = MLXDR.fromOperationsBundle([createOp]); + + const recreatedOps = MLXDR.toOperationsBundle(opBundleMLXDR); + + assertEquals(recreatedOps.length, 1); + assertEquals(recreatedOps[0].getOperation(), UTXOOperationType.CREATE); + assertEquals(recreatedOps[0].getAmount(), 10n); + assert(recreatedOps[0].isCreate()); + + assertEquals( + (recreatedOps[0] as CreateOperation).getUtxo().toString(), + validUtxo.toString(), + ); + }); + + it("should convert to and from multiple Operations", () => { + const createOpA = MoonlightOperation.create(validUtxo, 10n); + const createOpB = MoonlightOperation.create(validUtxo, 20n); + const createOpC = MoonlightOperation.create(validUtxo, 30n); + + const opBundleMLXDR = MLXDR.fromOperationsBundle([ + createOpA, + createOpB, + createOpC, + ]); + + const recreatedOps = MLXDR.toOperationsBundle(opBundleMLXDR); + + assertEquals(recreatedOps.length, 3); + + assertEquals(recreatedOps[0].getOperation(), UTXOOperationType.CREATE); + assertEquals(recreatedOps[0].getAmount(), 10n); + assert(recreatedOps[0].isCreate()); + assertEquals( + (recreatedOps[0] as CreateOperation).getUtxo().toString(), + validUtxo.toString(), + ); + + assertEquals(recreatedOps[1].getOperation(), UTXOOperationType.CREATE); + assertEquals(recreatedOps[1].getAmount(), 20n); + assert(recreatedOps[1].isCreate()); + assertEquals( + (recreatedOps[1] as CreateOperation).getUtxo().toString(), + validUtxo.toString(), + ); + + assertEquals(recreatedOps[2].getOperation(), UTXOOperationType.CREATE); + assertEquals(recreatedOps[2].getAmount(), 30n); + assert(recreatedOps[2].isCreate()); + assertEquals( + (recreatedOps[2] as CreateOperation).getUtxo().toString(), + validUtxo.toString(), + ); + }); + + it("should convert to and from multiple mixed operations", () => { + const createOp = MoonlightOperation.create(validUtxo, 10n); + const depositOp = MoonlightOperation.deposit(validPublicKey, 20n); + const withdrawOp = MoonlightOperation.withdraw(validPublicKey, 30n); + const spendOp = MoonlightOperation.spend(validUtxo); + + const opBundleMLXDR = MLXDR.fromOperationsBundle([ + createOp, + depositOp, + withdrawOp, + spendOp, + ]); + + const recreatedOps = MLXDR.toOperationsBundle(opBundleMLXDR); + + assertEquals(recreatedOps.length, 4); + + assertEquals(recreatedOps[0].getOperation(), UTXOOperationType.CREATE); + assertEquals(recreatedOps[0].getAmount(), 10n); + assert(recreatedOps[0].isCreate()); + assertEquals( + (recreatedOps[0] as CreateOperation).getUtxo().toString(), + validUtxo.toString(), + ); + + assertEquals(recreatedOps[1].getOperation(), UTXOOperationType.DEPOSIT); + assertEquals(recreatedOps[1].getAmount(), 20n); + assert(recreatedOps[1].isDeposit()); + assertEquals( + (recreatedOps[1] as DepositOperation).getPublicKey().toString(), + validPublicKey.toString(), + ); + + assertEquals(recreatedOps[2].getOperation(), UTXOOperationType.WITHDRAW); + assertEquals(recreatedOps[2].getAmount(), 30n); + assert(recreatedOps[2].isWithdraw()); + assertEquals( + (recreatedOps[2] as WithdrawOperation).getPublicKey().toString(), + validPublicKey.toString(), + ); + + assertEquals(recreatedOps[3].getOperation(), UTXOOperationType.SPEND); + assert(recreatedOps[3].isSpend()); + assertEquals( + (recreatedOps[3] as SpendOperation).getUtxo().toString(), + validUtxo.toString(), + ); + }); + + it("should handle signed operations in an Operation Bundle", async () => { + const userSigner = LocalSigner.generateRandom(); + const spender = new UTXOKeypairBase(await generateP256KeyPair()); + const createOp = MoonlightOperation.create(validUtxo, 1000n); + + const depositOp = await MoonlightOperation.deposit( + userSigner.publicKey() as Ed25519PublicKey, + 1000n, + ).addCondition(createOp.toCondition()).signWithEd25519( + userSigner, + 100000, + channelId, + assetId, + network, + ); + + const spendOp = await MoonlightOperation.spend( + spender.publicKey, + ).addCondition(createOp.toCondition()).signWithUTXO( + spender, + channelId, + 1000, + ); + + const withdrawOp = await MoonlightOperation.withdraw( + userSigner.publicKey() as Ed25519PublicKey, + 500n, + ).addCondition(createOp.toCondition()); + + const opBundleMLXDR = MLXDR.fromOperationsBundle([ + createOp, + depositOp, + withdrawOp, + spendOp, + ]); + + const recreatedOps = MLXDR.toOperationsBundle(opBundleMLXDR); + + assertEquals(recreatedOps.length, 4); + + // Validate create Operation Signature + assertEquals(recreatedOps[0].getOperation(), UTXOOperationType.CREATE); + assertEquals(recreatedOps[0].getAmount(), 1000n); + assert(recreatedOps[0].isCreate()); + assertEquals( + (recreatedOps[0] as CreateOperation).getUtxo().toString(), + validUtxo.toString(), + ); + + // Validate deposit Operation Signature + assertEquals(recreatedOps[1].getOperation(), UTXOOperationType.DEPOSIT); + assertEquals(recreatedOps[1].getAmount(), 1000n); + assert(recreatedOps[1].isDeposit()); + assertEquals( + (recreatedOps[1] as DepositOperation).getPublicKey().toString(), + userSigner.publicKey().toString(), + ); + assertEquals( + (recreatedOps[1] as DepositOperation).isSignedByEd25519(), + true, + ); + assertExists( + (recreatedOps[1] as DepositOperation).getEd25519Signature(), + ); + assertEquals( + (recreatedOps[1] as DepositOperation).getConditions(), + depositOp.getConditions(), + ); + // Validate withdraw Operation Signature + assertEquals(recreatedOps[2].getOperation(), UTXOOperationType.WITHDRAW); + assertEquals(recreatedOps[2].getAmount(), 500n); + assert(recreatedOps[2].isWithdraw()); + assertEquals( + (recreatedOps[2] as WithdrawOperation).getPublicKey().toString(), + userSigner.publicKey().toString(), + ); + + assertEquals( + (recreatedOps[2] as WithdrawOperation).getConditions(), + withdrawOp.getConditions(), + ); + // Validate spend Operation Signature + assertEquals(recreatedOps[3].getOperation(), UTXOOperationType.SPEND); + assert(recreatedOps[3].isSpend()); + assertEquals( + (recreatedOps[3] as SpendOperation).getUtxo().toString(), + spender.publicKey.toString(), + ); + assertEquals( + (recreatedOps[3] as SpendOperation).isSignedByUTXO(), + true, + ); + assertExists( + (recreatedOps[3] as SpendOperation).getUTXOSignature(), + ); + assertEquals( + (recreatedOps[3] as SpendOperation).getConditions(), + spendOp.getConditions(), + ); + }); + }); +}); diff --git a/src/custom-xdr/types.ts b/src/custom-xdr/types.ts index 5d9862a..4b23298 100644 --- a/src/custom-xdr/types.ts +++ b/src/custom-xdr/types.ts @@ -8,8 +8,8 @@ export enum MLXDRTypeByte { SpendOperation = 0x05, DepositOperation = 0x06, WithdrawOperation = 0x07, - - TransactionBundle = 0x08, + OperationsBundle = 0x08, + TransactionBundle = 0x09, } export const MLXDRPrefix: Buffer = Buffer.from([0x30, 0xb0]); diff --git a/src/operation/index.unit.test.ts b/src/operation/index.unit.test.ts index 7cf5f9a..62ca04e 100644 --- a/src/operation/index.unit.test.ts +++ b/src/operation/index.unit.test.ts @@ -12,9 +12,9 @@ import { UTXOOperationType } from "./types.ts"; import type { CreateCondition } from "../conditions/types.ts"; import { Condition } from "../conditions/index.ts"; import { Asset, Networks } from "@stellar/stellar-sdk"; -import { UTXOKeypairBase } from "@moonlight/moonlight-sdk"; +import { UTXOKeypairBase } from "../core/utxo-keypair-base/index.ts"; -describe("Condition", () => { +describe("Operation", () => { let validPublicKey: Ed25519PublicKey; let validUtxo: UTXOPublicKey; diff --git a/src/transaction-builder/signing.unit.test.ts b/src/transaction-builder/signing.unit.test.ts new file mode 100644 index 0000000..2b8960e --- /dev/null +++ b/src/transaction-builder/signing.unit.test.ts @@ -0,0 +1,71 @@ +import { assertExists } from "@std/assert/exists"; +import { assertEquals } from "@std/assert/equals"; +import { MoonlightTransactionBuilder } from "./index.ts"; +import { beforeAll, describe, it } from "@std/testing/bdd"; +import { type ContractId, LocalSigner } from "@colibri/core"; +import { Asset, Networks } from "@stellar/stellar-sdk"; +import { Condition } from "../conditions/index.ts"; +import { MoonlightOperation } from "../operation/index.ts"; + +describe("MoonlightTransactionBuilder", () => { + let validAmount: bigint; + let channelId: ContractId; + let authId: ContractId; + let assetId: ContractId; + let network: string; + + beforeAll(() => { + validAmount = 1000n; + channelId = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" as ContractId; + authId = + "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" as ContractId; + network = Networks.TESTNET; + assetId = Asset.native().contractId(network) as ContractId; + }); + + describe("Signing", () => { + it("should create the same signature for individual operations and bundle", async () => { + const userSigner = LocalSigner.generateRandom(); + + const condition = Condition.deposit(userSigner.publicKey(), validAmount); + const rawOperation = MoonlightOperation.deposit( + userSigner.publicKey(), + validAmount, + ) + .addCondition(condition); + const signedOperation = await MoonlightOperation.deposit( + userSigner.publicKey(), + validAmount, + ) + .addCondition(condition).signWithEd25519( + userSigner, + 100000, + channelId, + assetId, + network, + ); + const testBuilder = new MoonlightTransactionBuilder({ + channelId, + authId, + assetId, + network, + }); + + testBuilder.addOperation(rawOperation); + + await testBuilder.signExtWithEd25519( + userSigner, + 100000, + ); + + const signedOpFromBundle = testBuilder.getDepositOperations()[0]; + + assertExists(signedOpFromBundle); + assertEquals( + signedOpFromBundle.getEd25519Signature().toString(), + signedOperation.getEd25519Signature().toString(), + ); + }); + }); +});