Skip to content

Instantly share code, notes, and snippets.

@aristidesstaffieri
Created June 28, 2023 15:35
Show Gist options
  • Save aristidesstaffieri/87d8dc0a9b3fb4562078343056d940d9 to your computer and use it in GitHub Desktop.
Save aristidesstaffieri/87d8dc0a9b3fb4562078343056d940d9 to your computer and use it in GitHub Desktop.
JS Atomic Swap
// Pre-reqs - Node.js >= 16.4
// mkdir atomic-swap && cd atomic-swap
// npm init -y
// npm i bignumber.js soroban-client
// mkdir index.js and add this script
// get 3 accounts ready, deploy 2 tokens(0 decimals for simplicity), then mint at least 2 of each to each of the swapper accounts
// and fill in the vars in main
// run with node index.js
const SorobanClient = require('soroban-client')
const BigNumber = require('bignumber.js')
const { Buffer } = require('node:buffer')
const { createHash } = require('node:crypto')
function sha256(content) {
return createHash('sha256').update(content).digest('base64')
}
const accountToScVal = (account) =>
new SorobanClient.Address(account).toScVal()
const numberToU64 = (value) => {
const bigi = BigInt(value)
return new SorobanClient.xdr.Uint64(
BigInt.asUintN(32, bigi),
BigInt.asUintN(64, bigi) >> 32n
)
}
const numberToI128 = (value) => {
const bigValue = BigNumber(value)
const b = BigInt(bigValue.toFixed(0))
const buf = bigintToBuf(b)
if (buf.length > 16) {
throw new Error('BigNumber overflows i128')
}
if (bigValue.isNegative()) {
// Clear the top bit
buf[0] &= 0x7f
}
// left-pad with zeros up to 16 bytes
const padded = Buffer.alloc(16)
buf.copy(padded, padded.length - buf.length)
console.debug({ value: value.toString(), padded })
if (bigValue.isNegative()) {
// Set the top bit
padded[0] |= 0x80
}
const hi = new SorobanClient.xdr.Int64(
bigNumberFromBytes(false, ...padded.slice(4, 8)).toNumber(),
bigNumberFromBytes(false, ...padded.slice(0, 4)).toNumber(),
)
const lo = new SorobanClient.xdr.Uint64(
bigNumberFromBytes(false, ...padded.slice(12, 16)).toNumber(),
bigNumberFromBytes(false, ...padded.slice(8, 12)).toNumber(),
)
return SorobanClient.xdr.ScVal.scvI128(
new SorobanClient.xdr.Int128Parts({ lo, hi }),
)
}
const bigintToBuf = (bn) => {
let hex = BigInt(bn).toString(16).replace(/^-/, '')
if (hex.length % 2) {
hex = `0${hex}`
}
const len = hex.length / 2
const u8 = new Uint8Array(len)
let i = 0
let j = 0
while (i < len) {
u8[i] = parseInt(hex.slice(j, j + 2), 16)
i += 1
j += 2
}
if (bn < BigInt(0)) {
// Set the top bit
u8[0] |= 0x80
}
return Buffer.from(u8)
}
const bigNumberFromBytes = (
signed,
...bytes
) => {
let sign = 1
if (signed && bytes[0] === 0x80) {
// top bit is set, negative number.
sign = -1
bytes[0] &= 0x7f
}
let b = BigInt(0)
for (const byte of bytes) {
b <<= BigInt(8)
b |= BigInt(byte)
}
return BigNumber(b.toString()).multipliedBy(sign)
}
async function assembleMultiSig(
raw,
networkPassphrase,
simulation,
signers,
contractId,
server
) {
if ('innerTransaction' in raw) {
// TODO: Handle feebump transactions
return assembleTransaction(
raw.innerTransaction,
networkPassphrase,
simulation,
)
}
if (
raw.operations.length !== 1 ||
raw.operations[0].type !== 'invokeHostFunction'
) {
throw new Error(
'unsupported operation type, must be only one InvokeHostFunctionOp in the transaction.',
)
}
const rawInvokeHostFunctionOp = raw.operations[0]
if (
!rawInvokeHostFunctionOp.functions ||
!simulation.results ||
rawInvokeHostFunctionOp.functions.length !== simulation.results.length
) {
throw new Error(
'preflight simulation results do not contain same count of HostFunctions that InvokeHostFunctionOp in the transaction has.',
)
}
const source = new SorobanClient.Account(raw.source, `${parseInt(raw.sequence, 10) - 1}`)
const classicFeeNum = parseInt(raw.fee, 10) || 0
const minResourceFeeNum = parseInt(simulation.minResourceFee, 10) || 0
const txnBuilder = new SorobanClient.TransactionBuilder(source, {
// automatically update the tx fee that will be set on the resulting tx
// to the sum of 'classic' fee provided from incoming tx.fee
// and minResourceFee provided by simulation.
//
// 'classic' tx fees are measured as the product of tx.fee * 'number of operations', In soroban contract tx,
// there can only be single operation in the tx, so can make simplification
// of total classic fees for the soroban transaction will be equal to incoming tx.fee + minResourceFee.
fee: (classicFeeNum + minResourceFeeNum).toString(),
memo: raw.memo,
networkPassphrase,
timebounds: raw.timeBounds,
ledgerbounds: raw.ledgerBounds,
minAccountSequence: raw.minAccountSequence,
minAccountSequenceAge: raw.minAccountSequenceAge,
minAccountSequenceLedgerGap: raw.minAccountSequenceLedgerGap,
extraSigners: raw.extraSigners,
})
// apply the pre-built Auth from simulation onto each Tx/Op/HostFunction
// invocation
const authDecoratedHostFunctions = simulation.results.map(
(functionSimulationResult, i) => {
const hostFn = rawInvokeHostFunctionOp.functions[i]
hostFn.auth(buildContractAuth(functionSimulationResult.auth, signers, networkPassphrase, contractId, server))
return hostFn
},
)
txnBuilder.addOperation(
SorobanClient.Operation.invokeHostFunctions({
functions: authDecoratedHostFunctions,
}),
)
// apply the pre-built Soroban Tx Data from simulation onto the Tx
const sorobanTxData = SorobanClient.xdr.SorobanTransactionData.fromXDR(
simulation.transactionData,
'base64',
)
txnBuilder.setSorobanData(sorobanTxData)
return txnBuilder.build()
}
function buildContractAuth(auths, signers, networkPassphrase, contractId, server) {
const contractAuths = []
if (auths) {
for (const authStr of auths) {
const contractAuth = SorobanClient.xdr.ContractAuth.fromXDR(authStr, 'base64')
console.log(contractAuth.rootInvocation().functionName().toString())
console.log(contractAuth.rootInvocation().subInvocations()[0].functionName().toString())
if (contractAuth.addressWithNonce()) {
const authAccount = contractAuth.addressWithNonce().address().accountId().toXDR('hex')
if (signers[authAccount]) {
const activeKeypair = signers[authAccount].keypair
let nonce = 0
const key = SorobanClient.xdr.LedgerKey.contractData(
new SorobanClient.xdr.LedgerKeyContractData({
contractId: Buffer.from(contractId).subarray(0, 32),
key: SorobanClient.xdr.ScVal.scvLedgerKeyNonce(
new SorobanClient.xdr.ScNonceKey({
nonceAddress:SorobanClient.xdr.ScAddress.scAddressTypeContract(
Buffer.from(activeKeypair.publicKey()).subarray(0, 32)
)
})
)
})
)
// Fetch the current contract nonce
server.getLedgerEntries([key]).then(function (response) {
if (response.entries && response.entries.length) {
const ledgerEntry = response.entries[0]
const parsed = SorobanClient.xdr.LedgerEntryData.fromXDR(ledgerEntry.xdr, 'base64')
console.log( JSON.stringify(parsed) )
nonce = parsed
}
})
const hashIDPreimageEnvelope = SorobanClient.xdr.HashIdPreimage.envelopeTypeContractAuth(
new SorobanClient.xdr.HashIdPreimageContractAuth({
networkId: Buffer.from(sha256(networkPassphrase)).subarray(0, 32),
nonce: numberToU64(nonce),
invocation: contractAuth.rootInvocation()
})
).toXDR('raw')
const signature = activeKeypair.sign(sha256(hashIDPreimageEnvelope))
// Need to double wrap with vec because of a preview 9 bug, fixed in preview 10
const sigBAccountSig = SorobanClient.xdr.ScVal.scvVec(
[
SorobanClient.xdr.ScVal.scvVec(
[
SorobanClient.xdr.ScVal.scvMap(
[
new SorobanClient.xdr.ScMapEntry({
key: SorobanClient.xdr.ScVal.scvSymbol('public_key'),
val: SorobanClient.xdr.ScVal.scvBytes(activeKeypair.rawPublicKey()),
}),
new SorobanClient.xdr.ScMapEntry({
key: SorobanClient.xdr.ScVal.scvSymbol('signature'),
val: SorobanClient.xdr.ScVal.scvBytes(Buffer.from(signature)),
})
]
)
]
)
]
)
contractAuth.signatureArgs([sigBAccountSig])
}
}
contractAuths.push(contractAuth)
}
}
return contractAuths
}
/*
Swap Args -
env: Env,
a: Address,
b: Address,
token_a: Address,
token_b: Address,
amount_a: i128,
min_b_for_a: i128,
amount_b: i128,
min_a_for_b: i128,
*/
const doSwap = async (
contractId,
tokenA,
tokenB,
amountA,
amountB,
minBForA,
minAForB,
server,
networkPassphrase,
keyPairA,
keyPairB,
deployer
) => {
const publicKeyA = keyPairA.publicKey()
const publicKeyB = keyPairB.publicKey()
const deployerKey = deployer.publicKey()
const accountA = await server.getAccount(publicKeyA)
const accountB = await server.getAccount(publicKeyB)
const source = await server.getAccount(deployerKey)
const swapContract = new SorobanClient.Contract(contractId)
const contractA = new SorobanClient.Contract(tokenA)
const contractB = new SorobanClient.Contract(tokenB)
const txBuilder = new SorobanClient.TransactionBuilder(source, {
fee: '100',
networkPassphrase,
})
const tx = txBuilder
.addOperation(
swapContract.call(
'swap',
...[
accountToScVal(publicKeyA),
accountToScVal(publicKeyB),
contractA.address().toScVal(),
contractB.address().toScVal(),
numberToI128(amountA),
numberToI128(minBForA),
numberToI128(amountB),
numberToI128(minAForB),
],
),
).setTimeout(SorobanClient.TimeoutInfinite)
try {
const builtTx = tx.build()
const sim = await server.simulateTransaction(builtTx)
const signers = {
[keyPairA.xdrPublicKey().toXDR('hex')]: {
keypair: keyPairA,
account: accountA
},
[keyPairB.xdrPublicKey().toXDR('hex')]: {
keypair: keyPairB,
account: accountB
}
}
const assembledTx = await assembleMultiSig(builtTx, networkPassphrase, sim, signers, contractId, server)
// Do we need to re-prepare the tx?
const preparedTransaction = await server.prepareTransaction(assembledTx)
preparedTransaction.sign(deployer)
const transactionResult = await server.sendTransaction(preparedTransaction)
console.log('TX RESULT')
console.log(transactionResult)
if (transactionResult.status === 'PENDING') {
let txResponse = await server.getTransaction(transactionResult.hash)
// Poll this until the status is not 'NOT_FOUND'
while (txResponse.status === 'NOT_FOUND') {
// See if the transaction is complete
txResponse = await server.getTransaction(transactionResult.hash)
// Wait a second
await new Promise((resolve) => setTimeout(resolve, 1000))
}
console.log(txResponse.resultXdr)
} else {
throw new Error(
`Unabled to submit transaction, status: ${transactionResult.status}`,
)
}
} catch (err) {
console.log('TX ERROR')
console.error(err)
}
}
async function main() {
const server = new SorobanClient.Server('https://rpc-futurenet.stellar.org/')
const networkPassphrase = SorobanClient.Networks.FUTURENET
const CONTRACT_ID = ''
const keyPairA = SorobanClient.Keypair.fromSecret('')
const keyPairB = SorobanClient.Keypair.fromSecret('')
const deployer = SorobanClient.Keypair.fromSecret('')
const tokenA = ''
const tokenB = ''
const amountA = '1'
const amountB = '1'
const minAForB = '1'
const minBForA = '1'
doSwap(
CONTRACT_ID,
tokenA,
tokenB,
amountA,
amountB,
minBForA,
minAForB,
server,
networkPassphrase,
keyPairA,
keyPairB,
deployer
)
}
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment