Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@
"dependencies": {
"@ledgerhq/hw-transport": "^6.34.0",
"@partisiablockchain/abi-client": "^6.0.0",
"@partisiablockchain/blockchain-api-transaction-client": "^6.142.0",
"@secata-public/bitmanipulation-ts": "^3.4.0",
"bip32-path": "^0.4.2",
"partisia-blockchain-applications-crypto": "^1.0.34",
"partisia-blockchain-applications-sdk": "^0.1.4"
},
"devDependencies": {
Expand Down
41 changes: 22 additions & 19 deletions src/repositories/contract-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,29 +114,32 @@ export class ContractRepository implements IContractRepository {
// Remove contract cache as the state will change
this.cleanCache(contractAddress)

// Loaded on demand. Signing pulls in the crypto stack -- several hundred
// kilobytes -- and reading contract state, which is what most consumers do,
// never reaches this method.
const { createTransaction } = await import('../transactions')
const backend = await this.signingBackend()

return createTransaction(this.rpc, backend, { contractAddress, payload, cost: gas, isMainnet })
}

/**
* Loaded on demand. Signing pulls in the crypto stack -- several hundred
* kilobytes -- and reading contract state, which is what most consumers do,
* never reaches this method.
*/
private async signingBackend() {
const backends = await import('../transactions/authentication')

switch (this.secrets.strategy) {
case 'privateKey': {
const { createTransactionFromPrivateKey } = await import('../transactions')
return createTransactionFromPrivateKey(this.rpc, contractAddress, this.secrets.privateKey, payload, isMainnet, gas)
}
case 'privateKey':
return backends.privateKeyBackend(this.secrets.privateKey)

case 'partisiaSdk': {
const { createTransactionFromPartisiaClient } = await import('../transactions')
return createTransactionFromPartisiaClient(this.rpc, this.secrets.partisiaSdk, contractAddress, payload, gas)
}
case 'partisiaSdk':
return backends.partisiaSdkBackend(this.secrets.partisiaSdk)

case 'MetaMask': {
const { createTransactionFromMetaMaskClient } = await import('../transactions')
return createTransactionFromMetaMaskClient(this.rpc, this.secrets.metaMask, contractAddress, payload, isMainnet, gas)
}
case 'MetaMask':
return backends.metaMaskBackend(this.secrets.metaMask)

case 'Ledger': {
const { createTransactionFromLedgerClient } = await import('../transactions')
return createTransactionFromLedgerClient(this.rpc, this.secrets.ledger, contractAddress, payload, isMainnet, gas)
}
case 'Ledger':
return backends.ledgerBackend(this.secrets.ledger)

default:
throw new Error('Signing strategy not found')
Expand Down
115 changes: 115 additions & 0 deletions src/transactions/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type LedgerTransport from "@ledgerhq/hw-transport"
import type PartisiaSdk from "partisia-blockchain-applications-sdk"
import type { SenderAuthentication } from "@partisiablockchain/blockchain-api-transaction-client"
import type { MetaMaskSdk } from "../interface"
import assert from "assert"

/**
* A signing backend, expressed as the official client's `SenderAuthentication`.
*
* `SignedTransaction.create` serializes the transaction and hands the bytes to
* `sign`, so each strategy only has to answer two questions: which address is
* signing, and what is the signature over these bytes. Everything the four
* strategies used to repeat -- serializing, digesting, concatenating, encoding
* -- happens once, inside the official client.
*/
export interface SigningBackend {
authentication: SenderAuthentication
/**
* Interactive signers prompt the user on every signature. A rejected
* broadcast must not silently ask them to confirm again, so these are not
* retried.
*/
interactive: boolean
}

const SNAP_ID = "npm:@partisiablockchain/snap"

/**
* The backends are loaded on demand. `@partisiablockchain/blockchain-api-transaction-client`
* carries elliptic and hash.js; the Ledger backend adds the `@ledgerhq`
* transport. Reading contract state reaches none of this.
*/
export const privateKeyBackend = async (privateKey: string): Promise<SigningBackend> => {
const { SenderAuthenticationKeyPair } = await import("@partisiablockchain/blockchain-api-transaction-client")

return {
authentication: SenderAuthenticationKeyPair.fromString(privateKey),
interactive: false,
}
}

export const ledgerBackend = async (transport: LedgerTransport): Promise<SigningBackend> => {
const { PartisiaLedgerClient, signatureToBuffer } = await import("./ledger")

const client = new PartisiaLedgerClient(transport)
// `getAddress` is synchronous on `SenderAuthentication`, and asking the
// device costs a round trip, so it is resolved once here.
const address = await client.getAddress()

return {
authentication: {
getAddress: () => address,
sign: async (transactionPayload, chainId) => {
const signature = await client.signTransaction(transactionPayload, chainId)

return signatureToBuffer(signature).toString("hex")
},
},
interactive: true,
}
}

export const metaMaskBackend = async (client: MetaMaskSdk): Promise<SigningBackend> => {
const address: string = await client.request({
method: "wallet_invokeSnap",
params: { snapId: SNAP_ID, request: { method: "get_address" } },
})

return {
authentication: {
getAddress: () => address,
sign: async (transactionPayload, chainId) => {
const signature: string = await client.request({
method: "wallet_invokeSnap",
params: {
snapId: SNAP_ID,
request: {
method: "sign_transaction",
params: { payload: transactionPayload.toString("hex"), chainId },
},
},
})
assert(Buffer.from(signature, "hex").length === 65)

return signature
},
},
interactive: true,
}
}

export const partisiaSdkBackend = (client: PartisiaSdk): SigningBackend => {
if (!client.connection) throw new Error('Client is not connected')

const address = client.connection.account.address

return {
authentication: {
getAddress: () => address,
sign: async (transactionPayload) => {
// `dontBroadcast` keeps the wallet from sending the transaction itself:
// the SDK broadcasts every strategy the same way, through the reader
// node, so the result is polled the same way too.
const { signature } = await client.signMessage({
payload: transactionPayload.toString("hex"),
payloadType: "hex",
dontBroadcast: true,
})

return signature
},
},
interactive: true,
}
}
25 changes: 0 additions & 25 deletions src/transactions/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,6 @@ export const builderToBytesBe = (rpc: RpcContractBuilder) => {

export const getChainId = (isMainnet: boolean): string => `Partisia Blockchain${isMainnet ? '' : ' Testnet'}`

export const serializeTransaction = async (
client: ShardedClient,
walletAddress: string,
contractAddress: string,
payload: Buffer,
cost: number | string,
validityInMillis: number = 120_000
) => {
// `builderToBytesBe` is imported from this module by the record and domain
// actions, which are on the read path. Keeping the crypto import dynamic means
// reading state never loads it.
const { serializedTransaction } = await import("partisia-blockchain-applications-crypto/lib/main/transaction")

const shardId = client.deriveShardId(walletAddress)
const nonce = await client.getNonce(walletAddress, shardId)
// Need to pass a number otherwise the internal library will throw an error
const validTo = (new Date().getTime() + validityInMillis) as unknown as string

return serializedTransaction(
{ nonce, cost, validTo },
{ contract: contractAddress },
payload
)
}

export const buildTransactionResult = (
client: ShardedClient,
shardId: number,
Expand Down
Loading
Loading