Two-step transfer vs one-step transfer
The default behavior for all tokens are a two-step transfer, this matches how funds are usually transferred in TradFi, however this is counter-intuitive in the blockchain world. Canton Coin supports setting up a “Transfer Pre-approval”, this allows a party to designate that he wants to auto-accept all incoming transfer, giving a similar behavior of the blockchain world.import { SDK, localNetStaticConfig } from '@canton-network/wallet-sdk'
export default async function () {
// it is important to configure the SDK correctly else you might run into connectivity or authentication issues
const sdk = await SDK.create({
auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
amulet: global.AMULET_NAMESPACE_CONFIG,
})
const myParty = global.EXISTING_PARTY_1
const myPrivateKey = global.EXISTING_PARTY_1_KEYS.privateKey
const createPreapprovalCommand =
await sdk.amulet.preapproval.command.create({
parties: {
receiver: myParty,
},
})
await sdk.ledger
.prepare({
partyId: myParty,
commands: createPreapprovalCommand,
})
.sign(myPrivateKey)
.execute({
partyId: myParty,
})
}
Accepting or rejecting a two-step transfer
If no Transfer pre-approval have been set up, then it is required to fetch incoming transfer instructions and consume either theAccept or Reject choice, this can be done easily using the Wallet SDK.
import { SDK, localNetStaticConfig } from '@canton-network/wallet-sdk'
export default async function () {
const sdk = await SDK.create({
auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
token: global.TOKEN_NAMESPACE_CONFIG,
})
const myParty = global.EXISTING_PARTY_1
//this returns a list of all transfer instructions, you can then accept or reject them
await sdk.token.transfer.pending(myParty)
}
import { SDK, localNetStaticConfig } from '@canton-network/wallet-sdk'
export default async function () {
const sdk = await SDK.create({
auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
token: global.TOKEN_NAMESPACE_CONFIG,
})
const myParty = global.EXISTING_PARTY_2
const myPrivateKey = global.EXISTING_PARTY_2_KEYS.privateKey
const Reject = true
const myPendingTransaction = await sdk.token.transfer.pending(myParty)
const myPendingTransactionCid = myPendingTransaction[0].contractId
if (Reject) {
//reject the transaction
const [rejectTransferCommand, disclosedContracts] =
await sdk.token.transfer.reject({
transferInstructionCid: myPendingTransactionCid,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: myParty,
commands: rejectTransferCommand,
disclosedContracts: disclosedContracts,
})
.sign(myPrivateKey)
.execute({ partyId: myParty })
} else {
//accept the transaction
const [acceptTransferCommand, disclosedContracts] =
await sdk.token.transfer.accept({
transferInstructionCid: myPendingTransactionCid,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: myParty,
commands: acceptTransferCommand,
disclosedContracts: disclosedContracts,
})
.sign(myPrivateKey)
.execute({ partyId: myParty })
}
}
Withdrawing a two-step transfer before it gets accepted
Apart from accepting or rejecting a transfer instruction, it is also possible for the sender towithdraw the offer, thereby retrieving the locked funds.
import { SDK, localNetStaticConfig } from '@canton-network/wallet-sdk'
export default async function () {
const sdk = await SDK.create({
auth: global.TOKEN_PROVIDER_CONFIG_DEFAULT,
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
token: global.TOKEN_NAMESPACE_CONFIG,
})
const myParty = global.EXISTING_PARTY_1
const myPrivateKey = global.EXISTING_PARTY_1_KEYS.privateKey
const myPendingTransaction = await sdk.token.transfer.pending(myParty)
const myPendingTransactionCid = myPendingTransaction[0].contractId
const [withdrawTransferCommand, disclosedContracts] =
await sdk.token.transfer.withdraw({
transferInstructionCid: myPendingTransactionCid,
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: myParty,
commands: withdrawTransferCommand,
disclosedContracts: disclosedContracts,
})
.sign(myPrivateKey)
.execute({ partyId: myParty })
}
How do I quickly setup transfer preapproval?
It is worth nothing that using the validator operator party as the providing party causes the transfer pre-approval to auto-renew. The below script setup transfer preapproval for Bob and performs a one-step transfer from Alice to Bob:import { Holding, PrettyContract } from '@canton-network/core-tx-parser'
import { localNetStaticConfig, SDK } from '@canton-network/wallet-sdk'
import { pino } from 'pino'
import {
TOKEN_NAMESPACE_CONFIG,
TOKEN_PROVIDER_CONFIG_DEFAULT,
AMULET_NAMESPACE_CONFIG,
} from './utils/index.js'
const logger = pino({ name: 'v1-05-preapproval', level: 'info' })
const sdk = await SDK.create({
auth: TOKEN_PROVIDER_CONFIG_DEFAULT,
ledgerClientUrl: localNetStaticConfig.LOCALNET_APP_USER_LEDGER_URL,
token: TOKEN_NAMESPACE_CONFIG,
amulet: AMULET_NAMESPACE_CONFIG,
})
await sdk.amulet.tapInternal('1000')
const aliceKeys = sdk.keys.generate()
const alice = await sdk.party.external
.create(aliceKeys.publicKey, {
partyHint: 'v1-05-alice',
})
.sign(aliceKeys.privateKey)
.execute()
const [amuletTapCommand, amuletTapDisclosedContracts] = await sdk.amulet.tap(
alice.partyId,
'10000'
)
await sdk.ledger
.prepare({
partyId: alice.partyId,
commands: amuletTapCommand,
disclosedContracts: amuletTapDisclosedContracts,
})
.sign(aliceKeys.privateKey)
.execute({ partyId: alice.partyId })
const bobKeys = sdk.keys.generate()
const bob = await sdk.party.external
.create(bobKeys.publicKey, {
partyHint: 'v1-05-bob',
})
.sign(bobKeys.privateKey)
.execute()
// --- TEST CREATE COMMAND
const createPreapprovalCommand = await sdk.amulet.preapproval.command.create({
parties: {
receiver: bob.partyId,
},
})
logger.info(
{ createPreapprovalCommand },
'Successfully created a preapproval command'
)
await sdk.ledger
.prepare({
partyId: bob.partyId,
commands: createPreapprovalCommand,
})
.sign(bobKeys.privateKey)
.execute({
partyId: bob.partyId,
})
logger.info('Successfully registered the preapproval.')
// --- TEST FETCH
const start = performance.now()
const fetchOnceStatus = await sdk.amulet.preapproval.fetchQuick(bob.partyId)
const end = performance.now()
const duration = end - start
if (duration < 1000) {
logger.info(
`Success! The operation was fast (${duration.toFixed(2)} ms) and fetchOnce status is ${fetchOnceStatus}.`
)
} else {
logger.warn(
`Warning: Operation took longer than 1 second (${(duration / 1000).toFixed(2)} s).`
)
}
logger.info('Fetching for preapproval status with retry')
const fetchedPreapprovalStatus = await sdk.amulet.preapproval.fetchStatus(
bob.partyId
)
logger.info({ fetchedPreapprovalStatus }, 'Fetched preapproval status')
const sentValue = 2000
const [transferCommand, transferDisclosedContracts] =
await sdk.token.transfer.create({
sender: alice.partyId,
recipient: bob.partyId,
amount: sentValue.toString(),
instrumentId: 'Amulet',
registryUrl: localNetStaticConfig.LOCALNET_REGISTRY_API_URL,
})
await sdk.ledger
.prepare({
partyId: alice.partyId,
commands: transferCommand,
disclosedContracts: transferDisclosedContracts,
})
.sign(aliceKeys.privateKey)
.execute({ partyId: alice.partyId })
logger.info({ sentValue }, 'Executed transfer from Alice to Bob with value:')
const aliceUtxos = await sdk.token.utxos.list({ partyId: alice.partyId })
const bobUtxos = await sdk.token.utxos.list({ partyId: bob.partyId })
const partyAmuletValue = (utxos: PrettyContract<Holding>[]) =>
utxos.reduce(
(acc, utxo) => acc + parseFloat(utxo.interfaceViewValue.amount),
0
)
const aliceAmuletValue = partyAmuletValue(aliceUtxos)
const bobAmuletValue = partyAmuletValue(bobUtxos)
if (aliceAmuletValue !== 8000 || bobAmuletValue !== 2000)
throw Error(
`Wrong end results for utxos: ${JSON.stringify({ aliceAmuletValue, bobAmuletValue })}`
)
logger.info({ aliceAmuletValue, bobAmuletValue }, 'Result:')
// --- TEST RENEW COMMAND
logger.info('Renewing preapproval...')
const start2 = performance.now()
const fetchOnceStatusWithPreapproval = await sdk.amulet.preapproval.fetchQuick(
bob.partyId
)
const end2 = performance.now()
const duration2 = end2 - start2
if (duration < 1000) {
logger.info(
`Success! The operation was fast (${duration2.toFixed(2)} ms) and fetchOnce status is ${fetchOnceStatusWithPreapproval}.`
)
} else {
logger.warn(
`Warning: Operation took longer than 1 second (${duration2.toFixed(2)} s).`
)
}
const newExpiresAt = new Date(fetchedPreapprovalStatus!.expiresAt)
newExpiresAt.setDate(newExpiresAt.getDate() + 2)
await sdk.amulet.preapproval.renew({
parties: {
receiver: bob.partyId,
},
expiresAt: newExpiresAt,
})
const fetchedStatusAfterRenew = await sdk.amulet.preapproval.fetchStatus(
bob.partyId,
{
oldCid: fetchedPreapprovalStatus!.contractId,
}
)
const before = fetchedPreapprovalStatus!.expiresAt
const after = fetchedStatusAfterRenew!.expiresAt
if (!(after.getTime() > before.getTime())) {
throw new Error(
`Expected expiresAt to increase after renewal. before=${fetchedPreapprovalStatus!.expiresAt.toISOString()} after=${fetchedStatusAfterRenew!.expiresAt.toISOString()}`
)
}
logger.info(
{
before: before.toISOString(),
after: after.toISOString(),
extendedSeconds: Math.round(
(after.getTime() - before.getTime()) / 1000
),
},
'TransferPreapproval expiry extended, managed to renew preapproval'
)
// --- TEST CANCEL COMMAND
logger.info('Testing out cancel command')
if (!fetchedStatusAfterRenew?.templateId) {
throw new Error('No preapproval found - fetchedPreapprovalStatus is null')
}
const [cancelPreapprovalCommand, cancelDisclosedContracts] =
await sdk.amulet.preapproval.command.cancel({
parties: {
receiver: bob.partyId,
},
})
if (!cancelPreapprovalCommand) {
throw Error(
'Cancel preapproval command is null even though one has been created before'
)
}
await sdk.ledger
.prepare({
partyId: bob.partyId,
commands: cancelPreapprovalCommand,
disclosedContracts: cancelDisclosedContracts,
})
.sign(bobKeys.privateKey)
.execute({
partyId: bob.partyId,
})
logger.info('Submitted cancel command; now polling')
const cancelled = await sdk.amulet.preapproval.fetchStatus(bob.partyId, {
cancelled: true,
})
const preapprovalACS = await sdk.ledger.acsReader.readJsContracts({
parties: [bob.partyId],
filterByParty: true,
})
const renewedPreapprovalStillActive = preapprovalACS.some(
(contract) => contract.contractId === fetchedStatusAfterRenew?.contractId
)
if (cancelled === null && !renewedPreapprovalStillActive) {
logger.info(`Successfully cancelled`)
}
How to renew or cancel a transfer preapproval
If you have used the validator operator party as the provider, then it will automatically renew the transfer preapproval approximately 20 days before expiry, however there are cases where you would like to perform the preapproval renewal manually:await amulet.preapproval.renew({
parties: {
receiver: myPartyId,
},
expiresAt: newExpiresAt,
})
const [cancelPreapprovalCommand, cancelDisclosedContracts] =
await amulet.preapproval.command.cancel({
parties: {
receiver: myPartyId,
},
})
await sdk.ledger
.prepare({
partyId: myPartyId,
commands: cancelPreapprovalCommand,
disclosedContracts: cancelDisclosedContracts,
})
.sign(myPrivateKey)
.execute({
partyId: myPartyId,
})