diff --git a/bun.lock b/bun.lock index 4f8941f..0c38a32 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@lifi/lintent", "dependencies": { + "borsh": "^2.0.0", "ky": "^1.12.0", "viem": "~2.45.1", }, @@ -67,6 +68,8 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "borsh": ["borsh@2.0.0", "", {}, "sha512-kc9+BgR3zz9+cjbwM8ODoUB4fs3X3I5A/HtX7LZKxCLaMrEeDFoBpnhZY//DTS1VZBSs6S5v46RZRbZjRFspEg=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun": ["bun@1.3.9", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.9", "@oven/bun-darwin-x64": "1.3.9", "@oven/bun-darwin-x64-baseline": "1.3.9", "@oven/bun-linux-aarch64": "1.3.9", "@oven/bun-linux-aarch64-musl": "1.3.9", "@oven/bun-linux-x64": "1.3.9", "@oven/bun-linux-x64-baseline": "1.3.9", "@oven/bun-linux-x64-musl": "1.3.9", "@oven/bun-linux-x64-musl-baseline": "1.3.9", "@oven/bun-windows-x64": "1.3.9", "@oven/bun-windows-x64-baseline": "1.3.9" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A=="], diff --git a/package.json b/package.json index 9874df0..7681600 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ ] }, "dependencies": { + "borsh": "^2.0.0", "ky": "^1.12.0", "viem": "~2.45.1" }, diff --git a/src/constants.ts b/src/constants.ts index 9376cbc..654a9cc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,3 +15,9 @@ export const MULTICHAIN_INPUT_SETTLER_ESCROW = "0xb912b4c38ab54b94D45Ac001484dEBcbb519Bc2B" as const; export const MULTICHAIN_INPUT_SETTLER_COMPACT = "0x1fccC0807F25A58eB531a0B5b4bf3dCE88808Ed7" as const; + + +// Solana config +export const SOLANA_INPUT_SETTLER_ESCROW = + "0x4186c46d62fb033aace3a262def7efbbef0591b8e98732bcd62edbbc0916da57" as const; + diff --git a/src/deps.ts b/src/deps.ts index db99abd..f586799 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -5,6 +5,7 @@ export type IntentDeps = { verifier: CoreVerifier, chainId: bigint, ) => `0x${string}` | undefined; + getSettler?: (chainId: bigint) => `0x${string}` | undefined; }; export type StandardOrderValidationDeps = { diff --git a/src/helpers/convert.ts b/src/helpers/convert.ts index 70a5c40..1640de2 100644 --- a/src/helpers/convert.ts +++ b/src/helpers/convert.ts @@ -21,6 +21,9 @@ export function toBigIntWithDecimals(value: number, decimals: number): bigint { } export function addressToBytes32(address: `0x${string}`): `0x${string}` { + if (address.length === 66) return address; // already bytes32 with 0x prefix + if (address.length === 64) return `0x${address}`; // already bytes32 without 0x + // Accept only EVM addresses here. if (address.length !== 42 && address.length !== 40) { throw new Error(`Invalid address length: ${address.length}`); } diff --git a/src/intent/create.ts b/src/intent/create.ts index 48aa8a9..2b08c63 100644 --- a/src/intent/create.ts +++ b/src/intent/create.ts @@ -5,27 +5,34 @@ import type { CompactLock, CreateIntentOptions, EscrowLock, + SolanaEscrowLock, MultichainOrder, StandardOrder, TokenContext, + SolanaStandardOrder, } from "../types/index"; import { MultichainOrderIntent } from "./multichain"; import { StandardOrderIntent } from "./standard"; import { buildMandateOutputs } from "./helpers/output-encoding"; import { ONE_DAY, ONE_HOUR, inputSettlerForLock } from "./helpers/shared"; +import { addressToBytes32 } from "../helpers/convert"; +import { SolanaStandardOrderIntent } from "./solanaStandard"; + /** * @notice Class representing a Li.Fi Intent. Contains intent abstractions and helpers. */ export class Intent { - private lock: EscrowLock | CompactLock; + private lock: EscrowLock | SolanaEscrowLock | CompactLock; - private user: `0x${string}`; + private walletUser: `0x${string}`; private inputs: TokenContext[]; private outputs: TokenContext[]; private getOracle: IntentDeps["getOracle"]; + private getSettler: IntentDeps["getSettler"]; private verifier: string; private exclusiveFor?: `0x${string}`; + private outputRecipient?: `0x${string}`; private _nonce?: bigint; private expiry = ONE_DAY; @@ -33,12 +40,14 @@ export class Intent { constructor(opts: CreateIntentOptions, deps: IntentDeps) { this.lock = opts.lock; - this.user = opts.account; + this.walletUser = opts.account; this.inputs = opts.inputTokens; this.outputs = opts.outputTokens; this.verifier = opts.verifier; this.getOracle = deps.getOracle; + this.getSettler = deps.getSettler; this.exclusiveFor = opts.exclusiveFor; + this.outputRecipient = opts.outputRecipient; } numInputChains() { @@ -98,12 +107,40 @@ export class Intent { ]); const currentTime = Math.floor(Date.now() / 1000); + const bytes32Recipient = this.outputRecipient ? addressToBytes32(this.outputRecipient) : addressToBytes32(this.walletUser); + + if (this.lock.type === "solanaEscrow") { + const inputOracle = this.getOracle(this.verifier, inputChain)!; + const solanaStandardOrder: SolanaStandardOrder = { + user: this.walletUser, + nonce: this.nonce(), + originChainId: inputChain, + fillDeadline: currentTime + this.fillDeadline, + expires: currentTime + this.expiry, + inputOracle, + input: { + token: firstInput.token.address, + amount: firstInput.amount, + }, + outputs: buildMandateOutputs({ + exclusiveFor: this.exclusiveFor, + outputTokens: this.outputs, + getOracle: this.getOracle, + getSettler: this.getSettler, + verifier: this.verifier, + sameChain: this.isSameChain(), + bytes32Recipient, + currentTime, + }), + }; + return new SolanaStandardOrderIntent(inputSettlerForLock(this.lock, false), solanaStandardOrder); + } const inputOracle = this.isSameChain() ? COIN_FILLER : this.getOracle(this.verifier, inputChain)!; - + const order: StandardOrder = { - user: this.user, + user: this.walletUser, nonce: this.nonce(), originChainId: inputChain, fillDeadline: currentTime + this.fillDeadline, @@ -114,9 +151,10 @@ export class Intent { exclusiveFor: this.exclusiveFor, outputTokens: this.outputs, getOracle: this.getOracle, + getSettler: this.getSettler, verifier: this.verifier, sameChain: this.isSameChain(), - recipient: this.user, + bytes32Recipient, currentTime, }), }; @@ -137,7 +175,7 @@ export class Intent { this.verifier, firstInput.token.chainId, )!; - + const bytes32Recipient = this.outputRecipient ? addressToBytes32(this.outputRecipient) : addressToBytes32(this.walletUser); const inputs: { chainId: bigint; inputs: [bigint, bigint][] }[] = [ ...new Set(this.inputs.map(({ token }) => token.chainId)), ].map((chain) => { @@ -160,8 +198,9 @@ export class Intent { }; }); + const order: MultichainOrder = { - user: this.user, + user: this.walletUser, nonce: this.nonce(), fillDeadline: currentTime + this.fillDeadline, expires: currentTime + this.expiry, @@ -170,9 +209,10 @@ export class Intent { exclusiveFor: this.exclusiveFor, outputTokens: this.outputs, getOracle: this.getOracle, + getSettler: this.getSettler, verifier: this.verifier, sameChain: false, - recipient: this.user, + bytes32Recipient, currentTime, }), inputs, @@ -181,7 +221,7 @@ export class Intent { return new MultichainOrderIntent( inputSettlerForLock(this.lock, true), order, - this.lock, + this.lock as EscrowLock | CompactLock, ); } diff --git a/src/intent/fromOrder.ts b/src/intent/fromOrder.ts index d93ac14..d4e6199 100644 --- a/src/intent/fromOrder.ts +++ b/src/intent/fromOrder.ts @@ -2,6 +2,7 @@ import type { CompactLock, EscrowLock, MultichainOrder, + SolanaStandardOrder, StandardOrder, } from "../types/index"; import { @@ -11,14 +12,20 @@ import { import { ResetPeriod } from "../compact/idLib"; import { MultichainOrderIntent } from "./multichain"; import { StandardOrderIntent } from "./standard"; +import { SolanaStandardOrderIntent } from "./solanaStandard"; -type OrderLike = StandardOrder | MultichainOrder; +type OrderLike = StandardOrder | SolanaStandardOrder | MultichainOrder; type StandardOrderToIntentOptions = { inputSettler: `0x${string}`; order: StandardOrder; }; +type SolanaStandardOrderToIntentOptions = { + inputSettler: `0x${string}`; + order: SolanaStandardOrder; +}; + type MultichainOrderToIntentOptions = { inputSettler: `0x${string}`; order: MultichainOrder; @@ -47,31 +54,40 @@ function inferLock(inputSettler: `0x${string}`): EscrowLock | CompactLock { } export function isStandardOrder(order: OrderLike): order is StandardOrder { - return "originChainId" in order; + return "originChainId" in order && "inputs" in order; +} + +export function isSolanaStandardOrder( + order: OrderLike, +): order is SolanaStandardOrder { + return "originChainId" in order && "input" in order; } export function orderToIntent( options: StandardOrderToIntentOptions, ): StandardOrderIntent; +export function orderToIntent( + options: SolanaStandardOrderToIntentOptions, +): SolanaStandardOrderIntent; export function orderToIntent( options: MultichainOrderToIntentOptions, ): MultichainOrderIntent; export function orderToIntent( options: OrderToIntentOptions, -): StandardOrderIntent | MultichainOrderIntent; +): StandardOrderIntent | SolanaStandardOrderIntent | MultichainOrderIntent; export function orderToIntent( options: | StandardOrderToIntentOptions + | SolanaStandardOrderToIntentOptions | MultichainOrderToIntentOptions | OrderToIntentOptions, -): StandardOrderIntent | MultichainOrderIntent { +): StandardOrderIntent | SolanaStandardOrderIntent | MultichainOrderIntent { const { inputSettler, order } = options; - if (isStandardOrder(order)) { - return new StandardOrderIntent(inputSettler, order); - } - return new MultichainOrderIntent( - inputSettler, - order, - (options as MultichainOrderToIntentOptions).lock ?? inferLock(inputSettler), - ); + + if (isStandardOrder(order)) return new StandardOrderIntent(inputSettler, order); + if (isSolanaStandardOrder(order)) return new SolanaStandardOrderIntent(inputSettler, order); + + const lock = "lock" in options ? options.lock ?? inferLock(inputSettler) : inferLock(inputSettler); + + return new MultichainOrderIntent(inputSettler, order, lock); } diff --git a/src/intent/helpers/output-encoding.ts b/src/intent/helpers/output-encoding.ts index a3e8262..59ffffd 100644 --- a/src/intent/helpers/output-encoding.ts +++ b/src/intent/helpers/output-encoding.ts @@ -18,18 +18,20 @@ export function buildMandateOutputs(options: { exclusiveFor?: `0x${string}`; outputTokens: TokenContext[]; getOracle: IntentDeps["getOracle"]; + getSettler?: IntentDeps["getSettler"]; verifier: CoreVerifier; sameChain: boolean; - recipient: `0x${string}`; + bytes32Recipient: `0x${string}`; currentTime: number; }): MandateOutput[] { const { exclusiveFor, outputTokens, getOracle, + getSettler, verifier, sameChain, - recipient, + bytes32Recipient, currentTime, } = options; @@ -50,8 +52,8 @@ export function buildMandateOutputs(options: { ); } - const outputSettler = COIN_FILLER; return outputTokens.map(({ token, amount }) => { + const outputSettler = getSettler?.(token.chainId) ?? COIN_FILLER; const outputOracle = sameChain ? addressToBytes32(outputSettler) : addressToBytes32(getOracle(verifier, token.chainId)!); @@ -61,7 +63,7 @@ export function buildMandateOutputs(options: { chainId: token.chainId, token: addressToBytes32(token.address), amount: amount, - recipient: addressToBytes32(recipient), + recipient: bytes32Recipient, callbackData: "0x", context, }; diff --git a/src/intent/helpers/shared.ts b/src/intent/helpers/shared.ts index 837469a..f171ab0 100644 --- a/src/intent/helpers/shared.ts +++ b/src/intent/helpers/shared.ts @@ -3,8 +3,9 @@ import { INPUT_SETTLER_ESCROW_LIFI, MULTICHAIN_INPUT_SETTLER_COMPACT, MULTICHAIN_INPUT_SETTLER_ESCROW, + SOLANA_INPUT_SETTLER_ESCROW, } from "../../constants"; -import type { CompactLock, EscrowLock } from "../../types"; +import type { CompactLock, EscrowLock, SolanaEscrowLock } from "../../types"; export const ONE_MINUTE = 60; export const ONE_HOUR = 60 * ONE_MINUTE; @@ -15,7 +16,7 @@ export function selectAllBut(arr: T[], index: number): T[] { } export function inputSettlerForLock( - lock: EscrowLock | CompactLock, + lock: EscrowLock | CompactLock | SolanaEscrowLock, multichain: boolean, ) { if (lock.type === "compact" && multichain === false) @@ -26,6 +27,8 @@ export function inputSettlerForLock( return INPUT_SETTLER_ESCROW_LIFI; if (lock.type === "escrow" && multichain === true) return MULTICHAIN_INPUT_SETTLER_ESCROW; + if (lock.type === "solanaEscrow" && multichain === false) + return SOLANA_INPUT_SETTLER_ESCROW; throw new Error( `Not supported | multichain: ${multichain}, type: ${lock.type}`, diff --git a/src/intent/index.ts b/src/intent/index.ts index b478558..d70fc18 100644 --- a/src/intent/index.ts +++ b/src/intent/index.ts @@ -1,5 +1,10 @@ export { Intent } from "./create"; -export { isStandardOrder, orderToIntent } from "./fromOrder"; +export { + SolanaStandardOrderIntent, + computeSolanaStandardOrderId, + standardOrderToSolanaOrder, +} from "./solanaStandard"; +export { isStandardOrder, isSolanaStandardOrder, orderToIntent } from "./fromOrder"; export { StandardOrderIntent, computeStandardOrderId } from "./standard"; export type { OrderIntentCommon } from "./types"; export { diff --git a/src/intent/solanaStandard.ts b/src/intent/solanaStandard.ts new file mode 100644 index 0000000..78ab5d5 --- /dev/null +++ b/src/intent/solanaStandard.ts @@ -0,0 +1,141 @@ +import { hexToBytes, keccak256, numberToHex, pad } from "viem"; +import { serialize } from "borsh"; +import type { + MandateOutput, + SolanaStandardOrder, + StandardOrder, +} from "../types/index"; +import type { OrderIntentCommon } from "./types"; + +// -- Borsh schemas ---------------------------------------------------------- // +// Mirrors `common::types::StandardOrder` in catalyst-intent-svm. + +const bytes32 = { array: { type: "u8" as const, len: 32 } }; + +const mandateOutputSchema = { + struct: { + oracle: bytes32, + settler: bytes32, + chain_id: bytes32, + token: bytes32, + amount: bytes32, + recipient: bytes32, + callback_data: { array: { type: "u8" as const } }, + context: { array: { type: "u8" as const } }, + }, +}; + +const mandateInputSchema = { + struct: { + token: bytes32, + amount: "u64" as const, + }, +}; + +const standardOrderSchema = { + struct: { + user: bytes32, + nonce: "u128" as const, + origin_chain_id: "u128" as const, + expires: "u32" as const, + fill_deadline: "u32" as const, + input_oracle: bytes32, + input: mandateInputSchema, + outputs: { array: { type: mandateOutputSchema } }, + }, +}; + +// -- Helpers ---------------------------------------------------------------- // + +// Ensure the 32-bytes +const toBytes32 = (hex: `0x${string}`) => hexToBytes(pad(hex, { size: 32 })); +const bigintToBytes32 = (value: bigint) => hexToBytes(numberToHex(value, { size: 32 })); + +function toBorshOutput(o: MandateOutput) { + return { + oracle: toBytes32(o.oracle), + settler: toBytes32(o.settler), + chain_id: bigintToBytes32(o.chainId), + token: toBytes32(o.token), + amount: bigintToBytes32(o.amount), + recipient: toBytes32(o.recipient), + callback_data: hexToBytes(o.callbackData), + context: hexToBytes(o.context), + }; +} + +// -- Public API ------------------------------------------------------------- // + +export function borshEncodeSolanaOrder( + order: SolanaStandardOrder, +): Uint8Array { + return serialize(standardOrderSchema, { + user: toBytes32(order.user), + nonce: BigInt(order.nonce), + origin_chain_id: BigInt(order.originChainId), + expires: Number(order.expires), + fill_deadline: Number(order.fillDeadline), + input_oracle: toBytes32(order.inputOracle), + input: { + token: toBytes32(order.input.token), + amount: BigInt(order.input.amount), + }, + outputs: order.outputs.map(toBorshOutput), + }); +} + +export function computeSolanaStandardOrderId( + order: SolanaStandardOrder, +): `0x${string}` { + return keccak256(borshEncodeSolanaOrder(order)); +} + +// -- Conversion helpers ----------------------------------------------------- // + +export function standardOrderToSolanaOrder( + order: StandardOrder, +): SolanaStandardOrder { + const [firstInput] = order.inputs; + if (!firstInput) throw new Error("No inputs in order"); + const [tokenBigInt, amount] = firstInput; + return { + user: order.user, + nonce: order.nonce, + originChainId: order.originChainId, + expires: order.expires, + fillDeadline: order.fillDeadline, + inputOracle: order.inputOracle, + input: { + token: numberToHex(tokenBigInt, { size: 32 }), + amount, + }, + outputs: order.outputs, + }; +} + + +// -- Intent class ----------------------------------------------------------- // + +export class SolanaStandardOrderIntent + implements OrderIntentCommon +{ + inputSettler: `0x${string}`; + private readonly order: SolanaStandardOrder; + + constructor(inputSettler: `0x${string}`, order: SolanaStandardOrder) { + this.inputSettler = inputSettler; + this.order = order; + } + + asOrder(): SolanaStandardOrder { + return this.order; + } + + inputChains(): bigint[] { + return [this.order.originChainId]; + } + + orderId(): `0x${string}` { + return computeSolanaStandardOrderId(this.order); + } +} diff --git a/src/intent/types.ts b/src/intent/types.ts index a50b09e..44bae6c 100644 --- a/src/intent/types.ts +++ b/src/intent/types.ts @@ -1,13 +1,13 @@ -import type { MultichainOrder, StandardOrder } from "../types/index"; +import type { MultichainOrder, SolanaStandardOrder, StandardOrder } from "../types/index"; export interface OrderIntentCommon< - TOrder extends StandardOrder | MultichainOrder = + TOrder extends StandardOrder | SolanaStandardOrder | MultichainOrder = | StandardOrder + | SolanaStandardOrder | MultichainOrder, > { inputSettler: `0x${string}`; asOrder(): TOrder; inputChains(): bigint[]; orderId(): `0x${string}`; - compactClaimHash(): `0x${string}`; } diff --git a/src/types/createIntent.ts b/src/types/createIntent.ts index 216c016..3d63992 100644 --- a/src/types/createIntent.ts +++ b/src/types/createIntent.ts @@ -8,6 +8,7 @@ type CreateIntentOptionsBase = { outputTokens: TokenContext[]; verifier: CoreVerifier; account: `0x${string}`; + outputRecipient?: `0x${string}`; }; export type CreateIntentOptionsEscrow = CreateIntentOptionsBase & { diff --git a/src/types/lock.ts b/src/types/lock.ts index 021a372..f482620 100644 --- a/src/types/lock.ts +++ b/src/types/lock.ts @@ -10,6 +10,10 @@ export type EscrowLock = { type: "escrow"; }; +export type SolanaEscrowLock = { + type: "solanaEscrow"; +}; + export type CompactLock = { type: "compact"; resetPeriod: ResetPeriod; diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 8a0928a..ea4b0a5 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -9,6 +9,11 @@ export type MandateOutput = { context: `0x${string}`; }; +export type MandateInput = { + token: `0x${string}`; + amount: bigint; +}; + export type CompactMandate = { fillDeadline: number; inputOracle: `0x${string}`; diff --git a/src/types/order.ts b/src/types/order.ts index d443b7a..1bc0a4e 100644 --- a/src/types/order.ts +++ b/src/types/order.ts @@ -1,6 +1,17 @@ -import type { MandateOutput } from "./mandate"; +import type { MandateInput, MandateOutput } from "./mandate"; import type { NoSignature, Signature } from "./signature"; +export type SolanaStandardOrder = { + user: `0x${string}`; + nonce: bigint; + originChainId: bigint; + expires: number; + fillDeadline: number; + inputOracle: `0x${string}`; + input: MandateInput; + outputs: MandateOutput[]; +}; + export type StandardOrder = { user: `0x${string}`; nonce: bigint; @@ -37,7 +48,7 @@ export type MultichainOrder = { export type OrderContainer = { inputSettler: `0x${string}`; - order: StandardOrder | MultichainOrder; + order: StandardOrder | SolanaStandardOrder | MultichainOrder; sponsorSignature: Signature | NoSignature; allocatorSignature: Signature | NoSignature; }; diff --git a/src/validation.ts b/src/validation.ts index a68a219..bb887b6 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -9,6 +9,7 @@ import { MULTICHAIN_INPUT_SETTLER_COMPACT, } from "./constants"; import { addressToBytes32 } from "./helpers/convert"; + import { isStandardOrder } from "./intent/index"; import type { OrderContainer, StandardOrder } from "./types/index";