Skip to content

Commit

Permalink
Merge pull request #459 from LF-Decentralized-Trust-labs/noto-atom
Browse files Browse the repository at this point in the history
Update BondTest to offer better counterparty protection
  • Loading branch information
awrichar authored Dec 10, 2024
2 parents 203a166 + 2e3d622 commit 8c37ea8
Show file tree
Hide file tree
Showing 25 changed files with 572 additions and 223 deletions.
11 changes: 11 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Bond example: run",
"request": "launch",
"runtimeArgs": [
"run",
"start"
],
"runtimeExecutable": "npm",
"type": "node",
"cwd": "${workspaceFolder}/example/bond"
},
{
"name": "Run Controller",
"type": "go",
Expand Down
1 change: 1 addition & 0 deletions core/go/pkg/testbed/testbed_jsonrpc_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ func (tb *testbed) mapTransaction(ctx context.Context, tx *components.PrivateTra
}

return &TransactionResult{
ID: tx.ID,
EncodedCall: encodedCall,
PreparedTransaction: preparedTransaction,
PreparedMetadata: tx.PreparedMetadata,
Expand Down
2 changes: 2 additions & 0 deletions core/go/pkg/testbed/testbed_transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
package testbed

import (
"github.com/google/uuid"
"github.com/kaleido-io/paladin/toolkit/pkg/pldapi"
"github.com/kaleido-io/paladin/toolkit/pkg/tktypes"
)

type TransactionResult struct {
ID uuid.UUID `json:"id"`
EncodedCall tktypes.HexBytes `json:"encodedCall"`
PreparedTransaction *pldapi.TransactionInput `json:"preparedTransaction"`
PreparedMetadata tktypes.RawJSON `json:"preparedMetadata"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
Expand Down Expand Up @@ -99,6 +100,8 @@ public record TransactionInput(

@JsonIgnoreProperties(ignoreUnknown = true)
public record TransactionResult(
@JsonProperty
String id,
@JsonProperty
JsonHex.Bytes encodedCall,
@JsonProperty
Expand Down
64 changes: 52 additions & 12 deletions doc-site/docs/tutorials/bond-issuance.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,26 @@ The "hooks" configuration points it to the private hooks contract that was deplo

For this token, "restrictMinting" is disabled, because the hooks can enforce more flexible rules on both mint and transfer.

#### Create factory for atomic transactions

```typescript
await paladin1.sendTransaction({
type: TransactionType.PUBLIC,
abi: atomFactoryJson.abi,
bytecode: atomFactoryJson.bytecode,
function: "",
from: bondIssuerUnqualified,
data: {},
});
```

Many programming patterns in Paladin will require a contract on the shared ledger that
can prepare and execute atomic transactions. This is provided by the
[Atom and AtomFactory](https://github.com/LF-Decentralized-Trust-labs/paladin/blob/main/solidity/contracts/shared/Atom.sol) contracts.

At least one instance of `AtomFactory` must be deployed to run this example. Once in place,
note that this same factory contract can be reused for atomic transactions of any composition.

### Bond issuance

#### Issue bond to custodian
Expand All @@ -166,8 +186,8 @@ await bondTracker.using(paladin2).beginDistribution(bondCustodian, {
discountPrice: 1,
minimumDenomination: 1,
});
const investorRegistry = await bondTracker.investorRegistry(bondIssuer);
await investorRegistry
const investorList = await bondTracker.investorList(bondIssuer);
await investorList
.using(paladin2)
.addInvestor(bondCustodian, { addr: investorAddress });
```
Expand Down Expand Up @@ -208,6 +228,7 @@ const bondSubscription = await newBondSubscription(
bondAddress_: notoBond.address,
units_: 100,
custodian_: bondCustodianAddress,
atomFactory_: atomFactoryAddress,
}
);
```
Expand Down Expand Up @@ -264,14 +285,28 @@ The `preparePayment` and `prepareBond` methods on the bond subscription contract
respective parties to encode their prepared transactions, in preparation for triggering an
atomic DvP (delivery vs. payment).

#### Prepare the atomic transaction for the swap

```typescript
await bondSubscription.using(paladin2).distribute(bondCustodian);
```

When both parties have prepared their individual transactions, they can be combined into a
single base ledger transaction. The `distribute()` method below is a private method on
the `BondSubscription` contract, but it triggers creation of a new `Atom` contract on the
base ledger which contains the encoded transactions prepared above.

Once an `Atom` is deployed, it can be used to execute all or none of the transactions it
contains. It can never be changed, executed partially, or executed more than once.

#### Approve delegation via the private contract

```typescript
await notoCash.using(paladin3).approveTransfer(investor, {
inputs: encodeStates(paymentTransfer.states.spent ?? []),
outputs: encodeStates(paymentTransfer.states.confirmed ?? []),
data: paymentTransfer.metadata.approvalParams.data,
delegate: investorCustodianGroup.address,
delegate: atomAddress,
});

await issuerCustodianGroup.approveTransition(
Expand All @@ -280,14 +315,15 @@ await issuerCustodianGroup.approveTransition(
txId: newTransactionId(),
transitionHash: bondTransfer2.metadata.approvalParams.transitionHash,
signatures: bondTransfer2.metadata.approvalParams.signatures,
delegate: investorCustodianGroup.address,
delegate: atomAddress,
}
);
```

In order for the private subscription contract to be able to facilitate the token exchange,
the base ledger address of the investor+custodian group must be designated as the approved
delegate for both the payment transfer and the bond transfer.
Once the `Atom` is deployed, it must be designated as the approved delegate for both
the payment transfer and the bond transfer. Because this binds a specific set of atomic
operations to a unique contract address, both parties can be assured that by approving
this address as a delegate, the only transaction that can take place is the agreed swap.

In the case of the payment, we use the `approveTransfer` method of Noto. For the bond,
which uses Pente custom logic to wrap the Noto token, we use the `approveTransition` method
Expand All @@ -296,15 +332,19 @@ of Pente.
#### Distribute the bond units by performing swap

```typescript
await bondSubscription.using(paladin2).distribute(bondCustodian, {
units_: 100,
await paladin2.sendTransaction({
type: TransactionType.PUBLIC,
abi: atomJson.abi,
function: "execute",
from: bondCustodianUnqualified,
to: atomAddress,
data: {},
});
```

Finally, the custodian uses the `distribute` method on the bond subscription contract to
trigger the exchange of the bond and payment.
Finally, the custodian executes the `Atom` to trigger the exchange of the bond and payment.

This private transaction will trigger the previously-prepared transactions for the cash
This will trigger the previously-prepared transactions for the cash
transfer and the bond transfer, and it will also trigger an external call to the public
bond tracker to decrease the advertised available supply of the bond.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.kaleido.paladin.pente.domain.PenteConfiguration.GroupTupleJSON;
import io.kaleido.paladin.pente.domain.helpers.BondSubscriptionHelper;
import io.kaleido.paladin.pente.domain.helpers.BondTrackerHelper;
import io.kaleido.paladin.pente.domain.helpers.NotoHelper;
import io.kaleido.paladin.pente.domain.helpers.PenteHelper;
import io.kaleido.paladin.pente.domain.helpers.*;
import io.kaleido.paladin.testbed.Testbed;
import io.kaleido.paladin.toolkit.*;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -144,6 +141,21 @@ void testBond() throws Exception {
"contracts/shared/BondTrackerPublic.sol/BondTrackerPublic.json",
"abi"
);
String atomFactoryBytecode = ResourceLoader.jsonResourceEntryText(
this.getClass().getClassLoader(),
"contracts/shared/Atom.sol/AtomFactory.json",
"bytecode"
);
JsonABI atomFactoryABI = JsonABI.fromJSONResourceEntry(
this.getClass().getClassLoader(),
"contracts/shared/Atom.sol/AtomFactory.json",
"abi"
);
JsonABI atomABI = JsonABI.fromJSONResourceEntry(
this.getClass().getClassLoader(),
"contracts/shared/Atom.sol/Atom.json",
"abi"
);

GroupTupleJSON issuerCustodianGroup = new GroupTupleJSON(
JsonHex.randomBytes32(), new String[]{bondIssuer, bondCustodian});
Expand Down Expand Up @@ -221,15 +233,23 @@ void testBond() throws Exception {
bondTracker.beginDistribution(bondCustodian, 1, 1);

// Add Alice as an allowed investor
var investorRegistry = bondTracker.investorRegistry(bondCustodian);
investorRegistry.addInvestor(bondCustodian, aliceAddress);
var investorList = bondTracker.investorList(bondCustodian);
investorList.addInvestor(bondCustodian, aliceAddress);

// Create the atom factory on the base ledger
String atomFactoryAddress = testbed.getRpcClient().request("testbed_deployBytecode",
"issuer",
atomFactoryABI,
atomFactoryBytecode,
new HashMap<String, String>());

// Alice deploys BondSubscription to the alice/custodian privacy group, to request subscription
// TODO: if Alice deploys, how can custodian trust it's the correct logic?
var bondSubscription = BondSubscriptionHelper.deploy(aliceCustodianInstance, alice, new HashMap<>() {{
put("bondAddress_", notoBond.address());
put("units_", 1000);
put("custodian_", custodianAddress);
put("atomFactory_", atomFactoryAddress);
}});

// Prepare the bond transfer (requires 2 calls to prepare, as the Noto transaction spawns a Pente transaction to wrap it)
Expand All @@ -243,32 +263,72 @@ void testBond() throws Exception {
bondTransfer.preparedTransaction().abi().getFirst(),
bondTransfer.preparedTransaction().data()
);
assertEquals("public", bondTransfer2.preparedTransaction().type());
var bondTransferMetadata = mapper.convertValue(bondTransfer2.preparedMetadata(), PenteHelper.PenteTransitionMetadata.class);

// Prepare the payment transfer
var paymentTransfer = notoCash.prepareTransfer(alice, bondCustodian, 1000);
assertEquals("public", paymentTransfer.preparedTransaction().type());
var paymentMetadata = mapper.convertValue(paymentTransfer.preparedMetadata(), NotoHelper.NotoTransferMetadata.class);

// Pass the prepared transfers to the subscription contract
bondSubscription.prepareBond(bondCustodian, bondTransfer2.preparedTransaction().to(), bondTransfer2.encodedCall());
bondSubscription.prepareBond(bondCustodian, bondTransfer2.preparedTransaction().to(), bondTransferMetadata.transitionWithApproval().encodedCall());
bondSubscription.preparePayment(alice, paymentTransfer.preparedTransaction().to(), paymentMetadata.transferWithApproval().encodedCall());

// Alice receives full bond distribution
var distributeTX = bondSubscription.distribute(bondCustodian);

// Look up the deployed Atom address
HashMap<String, Object> distributeReceipt = testbed.getRpcClient().request("ptx_getTransactionReceipt", distributeTX.id());
String distributeTXHash = distributeReceipt.get("transactionHash").toString();
List<HashMap<String, Object>> events = testbed.getRpcClient().request("bidx_decodeTransactionEvents",
distributeTXHash,
atomFactoryABI,
"");
var deployEvent = events.stream().filter(ev -> ev.get("soliditySignature").toString().startsWith("event AtomDeployed")).findFirst();
assertFalse(deployEvent.isEmpty());
var deployEventData = (HashMap<String, Object>) deployEvent.get().get("data");
var atomAddress = JsonHex.addressFrom(deployEventData.get("addr").toString());

// Alice approves payment transfer
notoCash.approveTransfer(
"alice",
paymentTransfer.inputStates(),
paymentTransfer.outputStates(),
paymentMetadata.approvalParams().data(),
aliceCustodianInstance.address());

// TODO: custodian should need to approve either Noto or Pente for the bond transfer
// Currently the encoded call that is returned is a fully endorsed Pente/BondTracker onTransfer(),
// which will in turn call Noto with a fully endorsed transfer().
// Either the Pente call needs to require approval, or the Noto call needs to be transferWithApproval()
// so that it requires approval.

// Alice receives full bond distribution
bondSubscription.distribute(bondCustodian, 1000);
atomAddress.toString());

// Custodian approves bond transfer
var txID = issuerCustodianInstance.approveTransition(
bondCustodian,
JsonHex.randomBytes32(),
atomAddress,
bondTransferMetadata.approvalParams().transitionHash(),
bondTransferMetadata.approvalParams().signatures());
var receipt = TestbedHelper.pollForReceipt(testbed, txID, 3000);
assertNotNull(receipt);

// Execute the Atom
txID = TestbedHelper.sendTransaction(testbed,
new Testbed.TransactionInput(
"public",
"",
bondCustodian,
atomAddress,
new HashMap<>(),
atomABI,
"execute"
));
receipt = TestbedHelper.pollForReceipt(testbed, txID, 3000);
assertNotNull(receipt);

// All prepared transactions should now be resolved
receipt = TestbedHelper.pollForReceipt(testbed, paymentTransfer.id(), 3000);
assertNotNull(receipt);
receipt = TestbedHelper.pollForReceipt(testbed, bondTransfer2.id(), 3000);
assertNotNull(receipt);
receipt = TestbedHelper.pollForReceipt(testbed, bondTransfer.id(), 3000);
assertNotNull(receipt);

// TODO: figure out how to test negative cases (such as when Pente reverts due to a non-allowed investor)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

package io.kaleido.paladin.pente.domain.helpers;

import io.kaleido.paladin.testbed.Testbed;
import io.kaleido.paladin.toolkit.JsonABI;
import io.kaleido.paladin.toolkit.JsonHex;
import io.kaleido.paladin.toolkit.ResourceLoader;
Expand Down Expand Up @@ -81,16 +82,13 @@ public void preparePayment(String sender, JsonHex.Address to, JsonHex.Bytes enco
);
}

public void distribute(String sender, int units) throws IOException {
public Testbed.TransactionResult distribute(String sender) throws IOException {
var method = abi.getABIEntry("function", "distribute");
pente.invoke(
return pente.invoke(
method.name(),
method.inputs(),
sender,
address,
new HashMap<>() {{
put("units_", units);
}}
);
new HashMap<>());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ public JsonHex.Address address() {
return address;
}

public InvestorRegistryHelper investorRegistry(String sender) throws IOException {
var method = abi.getABIEntry("function", "investorRegistry");
public InvestorListHelper investorList(String sender) throws IOException {
var method = abi.getABIEntry("function", "investorList");
var output = pente.call(
method.name(),
method.inputs(),
Expand All @@ -63,7 +63,7 @@ public InvestorRegistryHelper investorRegistry(String sender) throws IOException
address,
new HashMap<>()
);
return new InvestorRegistryHelper(pente, JsonHex.addressFrom(output.output()));
return new InvestorListHelper(pente, JsonHex.addressFrom(output.output()));
}

public String balanceOf(String sender, String account) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
import java.io.IOException;
import java.util.HashMap;

public class InvestorRegistryHelper {
public class InvestorListHelper {
final PenteHelper pente;
final JsonHex.Address address;

InvestorRegistryHelper(PenteHelper pente, JsonHex.Address address) {
InvestorListHelper(PenteHelper pente, JsonHex.Address address) {
this.pente = pente;
this.address = address;
}
Expand Down
Loading

0 comments on commit 8c37ea8

Please sign in to comment.